When deleting video app, change locations to Cal Video (#3022)
* tRPC endpoint to delete credentials * Replace deleted app with daily * Updated Jitisi logo * Update logo size * Update logo size * Fix type error * Remove type as prop * Add isPrismaArray to lib * Address feedback * Remove connect calednar * Expire payment sessions and cancel payment intent * Expire payment sessions and cancel payment intent * Refactor input Co-authored-by: Omar López <zomars@me.com> * Remove console.log Co-authored-by: Omar López <zomars@me.com> * Address feedback * Find connected account on Stripe * Fix type error * Add interactive transactions * Remove console.logs * Remove console.log Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
parent
67ca98018e
commit
89ef6c3f13
|
@ -6,6 +6,8 @@ import showToast from "@calcom/lib/notification";
|
||||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||||
import { Dialog } from "@calcom/ui/Dialog";
|
import { Dialog } from "@calcom/ui/Dialog";
|
||||||
|
|
||||||
|
import { trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
|
|
||||||
export default function DisconnectIntegration(props: {
|
export default function DisconnectIntegration(props: {
|
||||||
|
@ -14,32 +16,23 @@ export default function DisconnectIntegration(props: {
|
||||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
|
const { id } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const mutation = useMutation(
|
|
||||||
async () => {
|
const mutation = trpc.useMutation("viewer.deleteCredential", {
|
||||||
const res = await fetch("/api/integrations", {
|
onSettled: async () => {
|
||||||
method: "DELETE",
|
|
||||||
body: JSON.stringify({ id: props.id }),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Something went wrong");
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
async onSettled() {
|
|
||||||
await props.onOpenChange(modalOpen);
|
await props.onOpenChange(modalOpen);
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess: () => {
|
||||||
showToast(data.message, "success");
|
showToast("Integration deleted successfully", "success");
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
},
|
},
|
||||||
}
|
onError: () => {
|
||||||
);
|
throw new Error("Something went wrong");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
@ -49,7 +42,7 @@ export default function DisconnectIntegration(props: {
|
||||||
confirmBtnText={t("yes_remove_app")}
|
confirmBtnText={t("yes_remove_app")}
|
||||||
cancelBtnText="Cancel"
|
cancelBtnText="Cancel"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
mutation.mutate();
|
mutation.mutate({ id });
|
||||||
}}>
|
}}>
|
||||||
{t("are_you_sure_you_want_to_remove_this_app")}
|
{t("are_you_sure_you_want_to_remove_this_app")}
|
||||||
</ConfirmationDialogContent>
|
</ConfirmationDialogContent>
|
||||||
|
|
|
@ -151,6 +151,27 @@ export async function refund(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const closePayments = async (paymentIntentId: string, stripeAccount: string) => {
|
||||||
|
try {
|
||||||
|
// Expire all current sessions
|
||||||
|
const sessions = await stripe.checkout.sessions.list(
|
||||||
|
{
|
||||||
|
payment_intent: paymentIntentId,
|
||||||
|
},
|
||||||
|
{ stripeAccount }
|
||||||
|
);
|
||||||
|
for (const session of sessions.data) {
|
||||||
|
await stripe.checkout.sessions.expire(session.id, { stripeAccount });
|
||||||
|
}
|
||||||
|
// Then cancel the payment intent
|
||||||
|
await stripe.paymentIntents.cancel(paymentIntentId, { stripeAccount });
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) {
|
async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) {
|
||||||
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
|
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
|
||||||
await sendOrganizerPaymentRefundFailedEmail({
|
await sendOrganizerPaymentRefundFailedEmail({
|
||||||
|
|
|
@ -21,7 +21,6 @@ import Shell, { ShellSubHeading } from "@components/Shell";
|
||||||
import SkeletonLoader from "@components/apps/SkeletonLoader";
|
import SkeletonLoader from "@components/apps/SkeletonLoader";
|
||||||
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
||||||
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
||||||
import DisconnectStripeIntegration from "@components/integrations/DisconnectStripeIntegration";
|
|
||||||
import IntegrationListItem from "@components/integrations/IntegrationListItem";
|
import IntegrationListItem from "@components/integrations/IntegrationListItem";
|
||||||
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||||
|
|
||||||
|
@ -42,7 +41,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
||||||
if (credentialId) {
|
if (credentialId) {
|
||||||
if (type === "stripe_payment") {
|
if (type === "stripe_payment") {
|
||||||
return (
|
return (
|
||||||
<DisconnectStripeIntegration
|
<DisconnectIntegration
|
||||||
id={credentialId}
|
id={credentialId}
|
||||||
render={(btnProps) => (
|
render={(btnProps) => (
|
||||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,4 +1,4 @@
|
||||||
import { BookingStatus, MembershipRole, Prisma } from "@prisma/client";
|
import { BookingStatus, MembershipRole, AppCategories, Prisma } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
@ -8,8 +8,10 @@ import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||||
import { sendFeedbackEmail } from "@calcom/emails";
|
import { sendFeedbackEmail } from "@calcom/emails";
|
||||||
import { parseRecurringEvent } from "@calcom/lib";
|
import { sendCancelledEmails } from "@calcom/emails";
|
||||||
|
import { parseRecurringEvent, isPrismaObjOrUndefined } from "@calcom/lib";
|
||||||
import { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
|
import { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
|
||||||
|
import { closePayments } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||||
import jackson from "@lib/jackson";
|
import jackson from "@lib/jackson";
|
||||||
|
@ -942,6 +944,239 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
|
|
||||||
return locationOptions;
|
return locationOptions;
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
.mutation("deleteCredential", {
|
||||||
|
input: z.object({
|
||||||
|
id: z.number(),
|
||||||
|
}),
|
||||||
|
async resolve({ input, ctx }) {
|
||||||
|
const { id } = input;
|
||||||
|
|
||||||
|
const credential = await prisma.credential.findFirst({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
app: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
categories: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTypes = await prisma.eventType.findMany({
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
locations: true,
|
||||||
|
destinationCalendar: true,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const eventType of eventTypes) {
|
||||||
|
if (eventType.locations) {
|
||||||
|
// If it's a video, replace the location with Cal video
|
||||||
|
if (credential.app?.categories.includes(AppCategories.video)) {
|
||||||
|
// Find the user's event types
|
||||||
|
|
||||||
|
// Look for integration name from app slug
|
||||||
|
const integrationQuery =
|
||||||
|
credential.app?.slug === "msteams" ? "office365_video" : credential.app?.slug.split("-")[0];
|
||||||
|
|
||||||
|
// Check if the event type uses the deleted integration
|
||||||
|
|
||||||
|
// To avoid type errors, need to stringify and parse JSON to use array methods
|
||||||
|
const locationsSchema = z.array(z.object({ type: z.string() }));
|
||||||
|
const locations = locationsSchema.parse(eventType.locations);
|
||||||
|
|
||||||
|
const updatedLocations = locations.map((location: { type: string }) => {
|
||||||
|
if (location.type.includes(integrationQuery)) {
|
||||||
|
return { type: "integrations:daily" };
|
||||||
|
}
|
||||||
|
return location;
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.eventType.update({
|
||||||
|
where: {
|
||||||
|
id: eventType.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
locations: updatedLocations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a calendar, remove the destination claendar from the event type
|
||||||
|
if (credential.app?.categories.includes(AppCategories.calendar)) {
|
||||||
|
if (eventType.destinationCalendar?.integration === credential.type) {
|
||||||
|
await prisma.destinationCalendar.delete({
|
||||||
|
where: {
|
||||||
|
id: eventType.destinationCalendar.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings
|
||||||
|
if (credential.app?.categories.includes(AppCategories.payment)) {
|
||||||
|
if (eventType.price) {
|
||||||
|
await prisma.$transaction(async () => {
|
||||||
|
await prisma.eventType.update({
|
||||||
|
where: {
|
||||||
|
id: eventType.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
hidden: true,
|
||||||
|
price: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assuming that all bookings under this eventType need to be paid
|
||||||
|
const unpaidBookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
status: "PENDING",
|
||||||
|
paid: false,
|
||||||
|
payment: {
|
||||||
|
every: {
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
...bookingMinimalSelect,
|
||||||
|
recurringEventId: true,
|
||||||
|
userId: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
credentials: true,
|
||||||
|
email: true,
|
||||||
|
timeZone: true,
|
||||||
|
name: true,
|
||||||
|
destinationCalendar: true,
|
||||||
|
locale: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: true,
|
||||||
|
references: {
|
||||||
|
select: {
|
||||||
|
uid: true,
|
||||||
|
type: true,
|
||||||
|
externalCalendarId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payment: true,
|
||||||
|
paid: true,
|
||||||
|
eventType: {
|
||||||
|
select: {
|
||||||
|
recurringEvent: true,
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uid: true,
|
||||||
|
eventTypeId: true,
|
||||||
|
destinationCalendar: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const booking of unpaidBookings) {
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: {
|
||||||
|
id: booking.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: BookingStatus.CANCELLED,
|
||||||
|
cancellationReason: "Payment method removed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const payment of booking.payment) {
|
||||||
|
// Right now we only close payments on Stripe
|
||||||
|
const stripeKeysSchema = z.object({
|
||||||
|
stripe_user_id: z.string(),
|
||||||
|
});
|
||||||
|
const { stripe_user_id } = stripeKeysSchema.parse(credential.key);
|
||||||
|
await closePayments(payment.externalId, stripe_user_id);
|
||||||
|
await prisma.payment.delete({
|
||||||
|
where: {
|
||||||
|
id: payment.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.attendee.deleteMany({
|
||||||
|
where: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.bookingReference.deleteMany({
|
||||||
|
where: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||||
|
return {
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
language: {
|
||||||
|
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||||
|
locale: attendee.locale ?? "en",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendeesList = await Promise.all(attendeesListPromises);
|
||||||
|
const tOrganizer = await getTranslation(booking?.user?.locale ?? "en", "common");
|
||||||
|
|
||||||
|
await sendCancelledEmails({
|
||||||
|
type: booking?.eventType?.title as string,
|
||||||
|
title: booking.title,
|
||||||
|
description: booking.description,
|
||||||
|
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||||
|
startTime: booking.startTime.toISOString(),
|
||||||
|
endTime: booking.endTime.toISOString(),
|
||||||
|
organizer: {
|
||||||
|
email: booking?.user?.email as string,
|
||||||
|
name: booking?.user?.name ?? "Nameless",
|
||||||
|
timeZone: booking?.user?.timeZone as string,
|
||||||
|
language: { translate: tOrganizer, locale: booking?.user?.locale ?? "en" },
|
||||||
|
},
|
||||||
|
attendees: attendeesList,
|
||||||
|
uid: booking.uid,
|
||||||
|
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
||||||
|
location: booking.location,
|
||||||
|
destinationCalendar: booking.destinationCalendar || booking.user?.destinationCalendar,
|
||||||
|
cancellationReason: "Payment method removed by organizer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validated that credential is user's above
|
||||||
|
await prisma.credential.delete({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const viewerRouter = createRouter()
|
export const viewerRouter = createRouter()
|
||||||
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 8.4 KiB |
|
@ -8,7 +8,7 @@ datasource db {
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
previewFeatures = ["filterJson"]
|
previewFeatures = ["filterJson", "interactiveTransactions"]
|
||||||
}
|
}
|
||||||
|
|
||||||
generator zod {
|
generator zod {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user