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:
Joe Au-Yeung 2022-06-20 13:52:50 -04:00 committed by GitHub
parent 67ca98018e
commit 89ef6c3f13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 283 additions and 204 deletions

View File

@ -6,6 +6,8 @@ import showToast from "@calcom/lib/notification";
import { ButtonBaseProps } from "@calcom/ui/Button";
import { Dialog } from "@calcom/ui/Dialog";
import { trpc } from "@lib/trpc";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
export default function DisconnectIntegration(props: {
@ -14,32 +16,23 @@ export default function DisconnectIntegration(props: {
render: (renderProps: ButtonBaseProps) => JSX.Element;
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
}) {
const { id } = props;
const { t } = useLocale();
const [modalOpen, setModalOpen] = useState(false);
const mutation = useMutation(
async () => {
const res = await fetch("/api/integrations", {
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();
const mutation = trpc.useMutation("viewer.deleteCredential", {
onSettled: async () => {
await props.onOpenChange(modalOpen);
},
{
async onSettled() {
await props.onOpenChange(modalOpen);
},
onSuccess(data) {
showToast(data.message, "success");
setModalOpen(false);
},
}
);
onSuccess: () => {
showToast("Integration deleted successfully", "success");
setModalOpen(false);
},
onError: () => {
throw new Error("Something went wrong");
},
});
return (
<>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
@ -49,7 +42,7 @@ export default function DisconnectIntegration(props: {
confirmBtnText={t("yes_remove_app")}
cancelBtnText="Cancel"
onConfirm={() => {
mutation.mutate();
mutation.mutate({ id });
}}>
{t("are_you_sure_you_want_to_remove_this_app")}
</ConfirmationDialogContent>

View File

@ -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 }) {
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
await sendOrganizerPaymentRefundFailedEmail({

View File

@ -21,7 +21,6 @@ import Shell, { ShellSubHeading } from "@components/Shell";
import SkeletonLoader from "@components/apps/SkeletonLoader";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
import DisconnectStripeIntegration from "@components/integrations/DisconnectStripeIntegration";
import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
@ -42,7 +41,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
if (credentialId) {
if (type === "stripe_payment") {
return (
<DisconnectStripeIntegration
<DisconnectIntegration
id={credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid="integration-connection-button">

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import { BookingStatus, MembershipRole, Prisma } from "@prisma/client";
import { BookingStatus, MembershipRole, AppCategories, Prisma } from "@prisma/client";
import dayjs from "dayjs";
import _ from "lodash";
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 { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
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 { closePayments } from "@ee/lib/stripe/server";
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import jackson from "@lib/jackson";
@ -942,6 +944,239 @@ const loggedInViewerRouter = createProtectedRouter()
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()

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@ -8,7 +8,7 @@ datasource db {
generator client {
provider = "prisma-client-js"
previewFeatures = ["filterJson"]
previewFeatures = ["filterJson", "interactiveTransactions"]
}
generator zod {