fix: Handle payment flow webhooks in case of event requiring confirmation (#11458)

Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
Hariom Balhara 2023-09-30 10:22:32 +05:30 committed by GitHub
parent 0bb99fc667
commit 20898e1505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 3379 additions and 1656 deletions

View File

@ -423,6 +423,7 @@
"booking_created": "Booking Created", "booking_created": "Booking Created",
"booking_rejected": "Booking Rejected", "booking_rejected": "Booking Rejected",
"booking_requested": "Booking Requested", "booking_requested": "Booking Requested",
"booking_payment_initiated": "Booking Payment Initiated",
"meeting_ended": "Meeting Ended", "meeting_ended": "Meeting Ended",
"form_submitted": "Form Submitted", "form_submitted": "Form Submitted",
"booking_paid": "Booking Paid", "booking_paid": "Booking Paid",

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma"; import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma"; import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";

View File

@ -1,20 +1,18 @@
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager"; import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import prismaMock from "../../../../tests/libs/__mocks__/prisma"; import prismock from "../../../../tests/libs/__mocks__/prisma";
import { diff } from "jest-diff"; import { diff } from "jest-diff";
import { describe, expect, vi, beforeEach, afterEach, test } from "vitest"; import { describe, expect, vi, beforeEach, afterEach, test } from "vitest";
import prisma from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums";
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types"; import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util"; import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
import { getDate, getGoogleCalendarCredential, createBookingScenario } from "../utils/bookingScenario"; import {
getDate,
// TODO: Mock properly getGoogleCalendarCredential,
prismaMock.eventType.findUnique.mockResolvedValue(null); createBookingScenario,
// @ts-expect-error Prisma v5 typings are not yet available } from "../utils/bookingScenario/bookingScenario";
prismaMock.user.findMany.mockResolvedValue([]);
vi.mock("@calcom/lib/constants", () => ({ vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true, IS_PRODUCTION: true,
@ -146,13 +144,13 @@ const TestData = {
}; };
const cleanup = async () => { const cleanup = async () => {
await prisma.eventType.deleteMany(); await prismock.eventType.deleteMany();
await prisma.user.deleteMany(); await prismock.user.deleteMany();
await prisma.schedule.deleteMany(); await prismock.schedule.deleteMany();
await prisma.selectedCalendar.deleteMany(); await prismock.selectedCalendar.deleteMany();
await prisma.credential.deleteMany(); await prismock.credential.deleteMany();
await prisma.booking.deleteMany(); await prismock.booking.deleteMany();
await prisma.app.deleteMany(); await prismock.app.deleteMany();
}; };
beforeEach(async () => { beforeEach(async () => {
@ -201,7 +199,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar], apps: [TestData.apps.googleCalendar],
}; };
// An event with one accepted booking // An event with one accepted booking
createBookingScenario(scenarioData); await createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({ const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
input: { input: {
@ -228,7 +226,7 @@ describe("getSchedule", () => {
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 }); const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
// An event with one accepted booking // An event with one accepted booking
createBookingScenario({ await createBookingScenario({
// An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours) // An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours)
eventTypes: [ eventTypes: [
{ {
@ -354,7 +352,7 @@ describe("getSchedule", () => {
}); });
test("slots are available as per `length`, `slotInterval` of the event", async () => { test("slots are available as per `length`, `slotInterval` of the event", async () => {
createBookingScenario({ await createBookingScenario({
eventTypes: [ eventTypes: [
{ {
id: 1, id: 1,
@ -453,7 +451,7 @@ describe("getSchedule", () => {
})() })()
); );
createBookingScenario({ await createBookingScenario({
eventTypes: [ eventTypes: [
{ {
id: 1, id: 1,
@ -569,7 +567,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar], apps: [TestData.apps.googleCalendar],
}; };
createBookingScenario(scenarioData); await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({ const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
input: { input: {
@ -643,7 +641,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar], apps: [TestData.apps.googleCalendar],
}; };
createBookingScenario(scenarioData); await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithCalBooking = await getSchedule({ const scheduleForEventOnADayWithCalBooking = await getSchedule({
input: { input: {
@ -701,7 +699,7 @@ describe("getSchedule", () => {
apps: [TestData.apps.googleCalendar], apps: [TestData.apps.googleCalendar],
}; };
createBookingScenario(scenarioData); await createBookingScenario(scenarioData);
const schedule = await getSchedule({ const schedule = await getSchedule({
input: { input: {
@ -765,7 +763,7 @@ describe("getSchedule", () => {
], ],
}; };
createBookingScenario(scenarioData); await createBookingScenario(scenarioData);
const scheduleForEventOnADayWithDateOverride = await getSchedule({ const scheduleForEventOnADayWithDateOverride = await getSchedule({
input: { input: {
@ -790,7 +788,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({ await createBookingScenario({
eventTypes: [ eventTypes: [
// A Collective Event Type hosted by this user // A Collective Event Type hosted by this user
{ {
@ -885,7 +883,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({ await createBookingScenario({
eventTypes: [ eventTypes: [
// An event having two users with one accepted booking // An event having two users with one accepted booking
{ {
@ -1010,7 +1008,7 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 }); const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
createBookingScenario({ await createBookingScenario({
eventTypes: [ eventTypes: [
// An event having two users with one accepted booking // An event having two users with one accepted booking
{ {

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma"; import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import type { EventType } from "@prisma/client"; import type { EventType } from "@prisma/client";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma"; import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { expect, it } from "vitest"; import { expect, it } from "vitest";

View File

@ -0,0 +1,88 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Payment, Prisma, PaymentOption, Booking } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import "vitest-fetch-mock";
import { sendAwaitingPaymentEmail } from "@calcom/emails";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
export function getMockPaymentService() {
function createPaymentLink(/*{ paymentUid, name, email, date }*/) {
return "http://mock-payment.example.com/";
}
const paymentUid = uuidv4();
const externalId = uuidv4();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
class MockPaymentService implements IAbstractPaymentService {
// TODO: We shouldn't need to implement adding a row to Payment table but that's a requirement right now.
// We should actually delegate table creation to the core app. Here, only the payment app specific logic should come
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
userId: Booking["userId"],
username: string | null,
bookerName: string | null,
bookerEmail: string,
paymentOption: PaymentOption
) {
const paymentCreateData = {
id: 1,
uid: paymentUid,
appId: null,
bookingId,
// booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
fee: 10,
success: true,
refunded: false,
data: {},
externalId,
paymentOption,
amount: payment.amount,
currency: payment.currency,
};
const paymentData = prismaMock.payment.create({
data: paymentCreateData,
});
logger.silly("Created mock payment", JSON.stringify({ paymentData }));
return paymentData;
}
async afterPayment(
event: CalendarEvent,
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
},
paymentData: Payment
): Promise<void> {
// TODO: App implementing PaymentService is supposed to send email by itself at the moment.
await sendAwaitingPaymentEmail({
...event,
paymentInfo: {
link: createPaymentLink(/*{
paymentUid: paymentData.uid,
name: booking.user?.name,
email: booking.user?.email,
date: booking.startTime.toISOString(),
}*/),
paymentOption: paymentData.paymentOption || "ON_BOOKING",
amount: paymentData.amount,
currency: paymentData.currency,
},
});
}
}
return {
paymentUid,
externalId,
MockPaymentService,
};
}

View File

@ -1,25 +1,23 @@
import appStoreMock from "../../../../tests/libs/__mocks__/app-store"; import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n"; import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
import prismaMock from "../../../../tests/libs/__mocks__/prisma"; import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type {
EventType as PrismaEventType,
User as PrismaUser,
Booking as PrismaBooking,
App as PrismaApp,
} from "@prisma/client";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client"; import type { WebhookTriggerEvents } from "@prisma/client";
import type Stripe from "stripe";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { expect } from "vitest";
import "vitest-fetch-mock"; import "vitest-fetch-mock";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import type { SchedulingType } from "@calcom/prisma/enums"; import type { SchedulingType } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums";
import type { NewCalendarEventType } from "@calcom/types/Calendar";
import type { EventBusyDate } from "@calcom/types/Calendar"; import type { EventBusyDate } from "@calcom/types/Calendar";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
import { getMockPaymentService } from "./MockPaymentService";
type App = { type App = {
slug: string; slug: string;
@ -78,7 +76,7 @@ type InputUser = typeof TestData.users.example & { id: number } & {
}[]; }[];
}; };
type InputEventType = { export type InputEventType = {
id: number; id: number;
title?: string; title?: string;
length?: number; length?: number;
@ -94,9 +92,11 @@ type InputEventType = {
beforeEventBuffer?: number; beforeEventBuffer?: number;
afterEventBuffer?: number; afterEventBuffer?: number;
requiresConfirmation?: boolean; requiresConfirmation?: boolean;
}; } & Partial<Omit<Prisma.EventTypeCreateInput, "users">>;
type InputBooking = { type InputBooking = {
id?: number;
uid?: string;
userId?: number; userId?: number;
eventTypeId: number; eventTypeId: number;
startTime: string; startTime: string;
@ -104,14 +104,40 @@ type InputBooking = {
title?: string; title?: string;
status: BookingStatus; status: BookingStatus;
attendees?: { email: string }[]; attendees?: { email: string }[];
references?: {
type: string;
uid: string;
meetingId?: string;
meetingPassword?: string;
meetingUrl?: string;
bookingId?: number;
externalCalendarId?: string;
deleted?: boolean;
credentialId?: number;
}[];
}; };
const Timezones = { const Timezones = {
"+5:30": "Asia/Kolkata", "+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka", "+6:00": "Asia/Dhaka",
}; };
logger.setSettings({ minLevel: "silly" });
function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { async function addEventTypesToDb(
eventTypes: (Prisma.EventTypeCreateInput & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
users?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflows?: any[];
})[]
) {
logger.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
await prismock.eventType.createMany({
data: eventTypes,
});
}
async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
const baseEventType = { const baseEventType = {
title: "Base EventType Title", title: "Base EventType Title",
slug: "base-event-type-slug", slug: "base-event-type-slug",
@ -119,7 +145,7 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
beforeEventBuffer: 0, beforeEventBuffer: 0,
afterEventBuffer: 0, afterEventBuffer: 0,
schedulingType: null, schedulingType: null,
length: 15,
//TODO: What is the purpose of periodStartDate and periodEndDate? Test these? //TODO: What is the purpose of periodStartDate and periodEndDate? Test these?
periodStartDate: new Date("2022-01-21T09:03:48.000Z"), periodStartDate: new Date("2022-01-21T09:03:48.000Z"),
periodEndDate: new Date("2022-01-21T09:03:48.000Z"), periodEndDate: new Date("2022-01-21T09:03:48.000Z"),
@ -150,170 +176,162 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
users, users,
}; };
}); });
logger.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
logger.silly("TestData: Creating EventType", eventTypes); await addEventTypesToDb(eventTypesWithUsers);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const eventTypeMock = ({ where }) => {
return new Promise((resolve) => {
const eventType = eventTypesWithUsers.find((e) => e.id === where.id) as unknown as PrismaEventType & {
users: PrismaUser[];
};
resolve(eventType);
});
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.eventType.findUnique.mockImplementation(eventTypeMock);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.eventType.findUniqueOrThrow.mockImplementation(eventTypeMock);
} }
async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[]) { function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
logger.silly("TestData: Creating Bookings", bookings); prismock.bookingReference.createMany({
data: bookingReferences,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.booking.findMany.mockImplementation((findManyArg) => {
// @ts-expect-error Prisma v5 breaks this
const where = findManyArg?.where || {};
return new Promise((resolve) => {
resolve(
// @ts-expect-error Prisma v5 breaks this
bookings
// We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed
.filter((booking) => {
/**
* A user is considered busy within a given time period if there
* is a booking they own OR host. This function mocks some of the logic
* for each condition. For details see the following ticket:
* https://github.com/calcom/cal.com/issues/6374
*/
// ~~ FIRST CONDITION ensures that this booking is owned by this user
// and that the status is what we want
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const statusIn = where.OR[0].status?.in || [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const userIdIn = where.OR[0].userId?.in || [];
const firstConditionMatches =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
statusIn.includes(booking.status) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(booking.userId === where.OR[0].userId || userIdIn.includes(booking.userId));
// We return this booking if either condition is met
return firstConditionMatches;
})
.map((booking) => ({
uid: uuidv4(),
title: "Test Booking Title",
...booking,
eventType: eventTypes.find((eventType) => eventType.id === booking.eventTypeId),
})) as unknown as PrismaBooking[]
);
});
}); });
} }
async function addWebhooks(webhooks: InputWebhook[]) { async function addBookingsToDb(
prismaMock.webhook.findMany.mockResolvedValue( bookings: (Prisma.BookingCreateInput & {
// @ts-expect-error Prisma v5 breaks this // eslint-disable-next-line @typescript-eslint/no-explicit-any
webhooks.map((webhook) => { references: any[];
return { })[]
...webhook, ) {
payloadTemplate: null, await prismock.booking.createMany({
secret: null, data: bookings,
id: uuidv4(), });
createdAt: new Date(), logger.silly(
userId: webhook.userId || null, "TestData: Booking as in DB",
eventTypeId: webhook.eventTypeId || null, JSON.stringify({
teamId: webhook.teamId || null, bookings: await prismock.booking.findMany({
}; include: {
references: true,
},
}),
}) })
); );
} }
function addUsers(users: InputUser[]) { async function addBookings(bookings: InputBooking[]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment logger.silly("TestData: Creating Bookings", JSON.stringify(bookings));
// @ts-ignore const allBookings = [...bookings].map((booking) => {
prismaMock.user.findUniqueOrThrow.mockImplementation((findUniqueArgs) => { if (booking.references) {
return new Promise((resolve) => { addBookingReferencesToDB(
// @ts-expect-error Prisma v5 breaks this booking.references.map((reference) => {
resolve({ return {
// @ts-expect-error Prisma v5 breaks this ...reference,
email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`, bookingId: booking.id,
} as unknown as PrismaUser); };
}); })
);
}
return {
uid: uuidv4(),
workflowReminders: [],
references: [],
title: "Test Booking Title",
...booking,
};
}); });
prismaMock.user.findMany.mockResolvedValue( await addBookingsToDb(
// @ts-expect-error Prisma v5 breaks this // eslint-disable-next-line @typescript-eslint/ban-ts-comment
users.map((user) => { //@ts-ignore
return { allBookings.map((booking) => {
...user, const bookingCreate = booking;
username: `IntegrationTestUser${user.id}`, if (booking.references) {
email: `IntegrationTestUser${user.id}@example.com`, bookingCreate.references = {
}; // eslint-disable-next-line @typescript-eslint/ban-ts-comment
}) as unknown as PrismaUser[] //@ts-ignore
createMany: {
data: booking.references,
},
};
}
return bookingCreate;
})
); );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function addWebhooksToDb(webhooks: any[]) {
await prismock.webhook.createMany({
data: webhooks,
});
}
async function addWebhooks(webhooks: InputWebhook[]) {
logger.silly("TestData: Creating Webhooks", webhooks);
await addWebhooksToDb(webhooks);
}
async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma.ScheduleCreateInput[] })[]) {
logger.silly("TestData: Creating Users", JSON.stringify(users));
await prismock.user.createMany({
data: users,
});
}
async function addUsers(users: InputUser[]) {
const prismaUsersCreate = users.map((user) => {
const newUser = user;
if (user.schedules) {
newUser.schedules = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
createMany: {
data: user.schedules.map((schedule) => {
return {
...schedule,
availability: {
createMany: {
data: schedule.availability,
},
},
};
}),
},
};
}
if (user.credentials) {
newUser.credentials = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
createMany: {
data: user.credentials,
},
};
}
return newUser;
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
await addUsersToDb(prismaUsersCreate);
}
export async function createBookingScenario(data: ScenarioData) { export async function createBookingScenario(data: ScenarioData) {
logger.silly("TestData: Creating Scenario", data); logger.silly("TestData: Creating Scenario", JSON.stringify({ data }));
addUsers(data.users); await addUsers(data.users);
const eventType = addEventTypes(data.eventTypes, data.users); const eventType = await addEventTypes(data.eventTypes, data.users);
if (data.apps) { if (data.apps) {
// @ts-expect-error Prisma v5 breaks this prismock.app.createMany({
prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]); data: data.apps,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment });
//@ts-ignore
const appMock = ({ where: { slug: whereSlug } }) => {
return new Promise((resolve) => {
if (!data.apps) {
resolve(null);
return;
}
const foundApp = data.apps.find(({ slug }) => slug == whereSlug);
//TODO: Pass just the app name in data.apps and maintain apps in a separate object or load them dyamically
resolve(
({
...foundApp,
...(foundApp?.slug ? TestData.apps[foundApp.slug as keyof typeof TestData.apps] || {} : {}),
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
categories: [],
} as PrismaApp) || null
);
});
};
// FIXME: How do we know which app to return?
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.app.findUnique.mockImplementation(appMock);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.app.findFirst.mockImplementation(appMock);
} }
data.bookings = data.bookings || []; data.bookings = data.bookings || [];
allowSuccessfulBookingCreation(); // allowSuccessfulBookingCreation();
addBookings(data.bookings, data.eventTypes); await addBookings(data.bookings);
// mockBusyCalendarTimes([]); // mockBusyCalendarTimes([]);
addWebhooks(data.webhooks || []); await addWebhooks(data.webhooks || []);
// addPaymentMock();
return { return {
eventType, eventType,
}; };
} }
// async function addPaymentsToDb(payments: Prisma.PaymentCreateInput[]) {
// await prismaMock.payment.createMany({
// data: payments,
// });
// }
/** /**
* This fn indents to /ally compute day, month, year for the purpose of testing. * This fn indents to /ally compute day, month, year for the purpose of testing.
* We are not using DayJS because that's actually being tested by this code. * We are not using DayJS because that's actually being tested by this code.
@ -372,9 +390,11 @@ export function getMockedCredential({
scope: string; scope: string;
}; };
}) { }) {
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
return { return {
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, type: app.type,
appId: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].slug, appId: app.slug,
app: app,
key: { key: {
expiry_date: Date.now() + 1000000, expiry_date: Date.now() + 1000000,
token_type: "Bearer", token_type: "Bearer",
@ -399,7 +419,16 @@ export function getZoomAppCredential() {
return getMockedCredential({ return getMockedCredential({
metadataLookupKey: "zoomvideo", metadataLookupKey: "zoomvideo",
key: { key: {
scope: "meeting:writed", scope: "meeting:write",
},
});
}
export function getStripeAppCredential() {
return getMockedCredential({
metadataLookupKey: "stripepayment",
key: {
scope: "read_write",
}, },
}); });
} }
@ -466,6 +495,7 @@ export const TestData = {
apps: { apps: {
"google-calendar": { "google-calendar": {
slug: "google-calendar", slug: "google-calendar",
enabled: true,
dirName: "whatever", dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
@ -479,6 +509,38 @@ export const TestData = {
"daily-video": { "daily-video": {
slug: "daily-video", slug: "daily-video",
dirName: "whatever", dirName: "whatever",
enabled: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
expiry_date: Infinity,
api_key: "",
scale_plan: "false",
client_id: "client_id",
client_secret: "client_secret",
redirect_uris: ["http://localhost:3000/auth/callback"],
},
},
zoomvideo: {
slug: "zoom",
enabled: true,
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
expiry_date: Infinity,
api_key: "",
scale_plan: "false",
client_id: "client_id",
client_secret: "client_secret",
redirect_uris: ["http://localhost:3000/auth/callback"],
},
},
"stripe-payment": {
//TODO: Read from appStoreMeta
slug: "stripe",
enabled: true,
dirName: "stripepayment",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
keys: { keys: {
@ -493,14 +555,6 @@ export const TestData = {
}, },
}; };
function allowSuccessfulBookingCreation() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.booking.create.mockImplementation(function (booking) {
return booking.data;
});
}
export class MockError extends Error { export class MockError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@ -540,6 +594,7 @@ export function getScenarioData({
usersApartFromOrganizer = [], usersApartFromOrganizer = [],
apps = [], apps = [],
webhooks, webhooks,
bookings,
}: // hosts = [], }: // hosts = [],
{ {
organizer: ReturnType<typeof getOrganizer>; organizer: ReturnType<typeof getOrganizer>;
@ -547,6 +602,7 @@ export function getScenarioData({
apps: ScenarioData["apps"]; apps: ScenarioData["apps"];
usersApartFromOrganizer?: ScenarioData["users"]; usersApartFromOrganizer?: ScenarioData["users"];
webhooks?: ScenarioData["webhooks"]; webhooks?: ScenarioData["webhooks"];
bookings?: ScenarioData["bookings"];
// hosts?: ScenarioData["hosts"]; // hosts?: ScenarioData["hosts"];
}) { }) {
const users = [organizer, ...usersApartFromOrganizer]; const users = [organizer, ...usersApartFromOrganizer];
@ -561,22 +617,28 @@ export function getScenarioData({
}); });
return { return {
// hosts: [...hosts], // hosts: [...hosts],
eventTypes: [...eventTypes], eventTypes: eventTypes.map((eventType, index) => {
return {
...eventType,
title: `Test Event Type - ${index + 1}`,
description: `It's a test event type - ${index + 1}`,
};
}),
users, users,
apps: [...apps], apps: [...apps],
webhooks, webhooks,
bookings: bookings || [],
}; };
} }
export function mockEnableEmailFeature() { export function enableEmailFeature() {
// @ts-expect-error Prisma v5 breaks this prismock.feature.create({
prismaMock.feature.findMany.mockResolvedValue([ data: {
{
slug: "emails", slug: "emails",
// It's a kill switch
enabled: false, enabled: false,
type: "KILL_SWITCH",
}, },
]); });
} }
export function mockNoTranslations() { export function mockNoTranslations() {
@ -589,20 +651,74 @@ export function mockNoTranslations() {
}); });
} }
export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof appStoreMetadata) { export function mockCalendarToHaveNoBusySlots(
metadataLookupKey: keyof typeof appStoreMetadata,
calendarData?: {
create: {
uid: string;
};
update?: {
uid: string;
};
}
) {
const appStoreLookupKey = metadataLookupKey; const appStoreLookupKey = metadataLookupKey;
const normalizedCalendarData = calendarData || {
create: {
uid: "MOCK_ID",
},
update: {
uid: "UPDATED_MOCK_ID",
},
};
logger.silly(`Mocking ${appStoreLookupKey} on appStoreMock`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createEventCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateEventCalls: any[] = [];
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({ appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
lib: { lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
CalendarService: function MockCalendarService() { CalendarService: function MockCalendarService() {
return { return {
createEvent: () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
createEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
const [calEvent, credentialId] = rest;
logger.silly(
"mockCalendarToHaveNoBusySlots.createEvent",
JSON.stringify({ calEvent, credentialId })
);
createEventCalls.push(rest);
return Promise.resolve({ return Promise.resolve({
type: "daily_video", type: app.type,
id: "dailyEventName", additionalInfo: {},
password: "dailyvideopass", uid: "PROBABLY_UNUSED_UID",
url: "http://dailyvideo.example.com", id: normalizedCalendarData.create.uid,
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
const [uid, event, externalCalendarId] = rest;
logger.silly(
"mockCalendarToHaveNoBusySlots.updateEvent",
JSON.stringify({ uid, event, externalCalendarId })
);
// eslint-disable-next-line prefer-rest-params
updateEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: normalizedCalendarData.update!.uid!,
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
}); });
}, },
getAvailability: (): Promise<EventBusyDate[]> => { getAvailability: (): Promise<EventBusyDate[]> => {
@ -614,16 +730,37 @@ export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof ap
}, },
}, },
}); });
return {
createEventCalls,
updateEventCalls,
};
} }
export function mockSuccessfulVideoMeetingCreation({ export function mockSuccessfulVideoMeetingCreation({
metadataLookupKey, metadataLookupKey,
appStoreLookupKey, appStoreLookupKey,
videoMeetingData,
}: { }: {
metadataLookupKey: string; metadataLookupKey: string;
appStoreLookupKey?: string; appStoreLookupKey?: string;
videoMeetingData?: {
password: string;
id: string;
url: string;
};
}) { }) {
appStoreLookupKey = appStoreLookupKey || metadataLookupKey; appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
videoMeetingData = videoMeetingData || {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-${metadataLookupKey}.example.com`,
};
logger.silly(
"mockSuccessfulVideoMeetingCreation",
JSON.stringify({ metadataLookupKey, appStoreLookupKey })
);
const createMeetingCalls: any[] = [];
const updateMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => { appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
@ -633,12 +770,31 @@ export function mockSuccessfulVideoMeetingCreation({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
VideoApiAdapter: () => ({ VideoApiAdapter: () => ({
createMeeting: () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeeting: (...rest: any[]) => {
createMeetingCalls.push(rest);
return Promise.resolve({ return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
id: "MOCK_ID", ...videoMeetingData,
password: "MOCK_PASS", });
url: `http://mock-${metadataLookupKey}.example.com`, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeeting: async (...rest: any[]) => {
const [bookingRef, calEvent] = rest;
updateMeetingCalls.push(rest);
if (!bookingRef.type) {
throw new Error("bookingRef.type is not defined");
}
if (!calEvent.organizer) {
throw new Error("calEvent.organizer is not defined");
}
logger.silly(
"mockSuccessfulVideoMeetingCreation.updateMeeting",
JSON.stringify({ bookingRef, calEvent })
);
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
}); });
}, },
}), }),
@ -646,6 +802,37 @@ export function mockSuccessfulVideoMeetingCreation({
}); });
}); });
}); });
return {
createMeetingCalls,
updateMeetingCalls,
};
}
export function mockPaymentApp({
metadataLookupKey,
appStoreLookupKey,
}: {
metadataLookupKey: string;
appStoreLookupKey?: string;
}) {
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
const { paymentUid, externalId, MockPaymentService } = getMockPaymentService();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
return new Promise((resolve) => {
resolve({
lib: {
PaymentService: MockPaymentService,
},
});
});
});
return {
paymentUid,
externalId,
};
} }
export function mockErrorOnVideoMeetingCreation({ export function mockErrorOnVideoMeetingCreation({
@ -675,39 +862,6 @@ export function mockErrorOnVideoMeetingCreation({
}); });
} }
export function expectWebhookToHaveBeenCalledWith(
subscriberUrl: string,
data: {
triggerEvent: WebhookTriggerEvents;
payload: { metadata: Record<string, unknown>; responses: Record<string, unknown> };
}
) {
const fetchCalls = fetchMock.mock.calls;
const webhookFetchCall = fetchCalls.find((call) => call[0] === subscriberUrl);
if (!webhookFetchCall) {
throw new Error(`Webhook not called with ${subscriberUrl}`);
}
expect(webhookFetchCall[0]).toBe(subscriberUrl);
const body = webhookFetchCall[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
console.log({ payload: parsedBody.payload });
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
: parsedBody.payload.metadata.videoCallUrl;
expect(parsedBody.payload.metadata).toContain(data.payload.metadata);
expect(parsedBody.payload.responses).toEqual(data.payload.responses);
}
export function expectWorkflowToBeTriggered() {
// TODO: Implement this.
}
export function expectBookingToBeInDatabase(booking: Partial<Prisma.BookingCreateInput>) {
const createBookingCalledWithArgs = prismaMock.booking.create.mock.calls[0];
expect(createBookingCalledWithArgs[0].data).toEqual(expect.objectContaining(booking));
}
export function getBooker({ name, email }: { name: string; email: string }) { export function getBooker({ name, email }: { name: string; email: string }) {
return { return {
name, name,
@ -715,40 +869,28 @@ export function getBooker({ name, email }: { name: string; email: string }) {
}; };
} }
declare global { export function getMockedStripePaymentEvent({ paymentIntentId }: { paymentIntentId: string }) {
// eslint-disable-next-line @typescript-eslint/no-namespace return {
namespace jest { id: null,
interface Matchers<R> { data: {
toHaveEmail(expectedEmail: { htmlToContain?: string; to: string }): R; object: {
} id: paymentIntentId,
} },
},
} as unknown as Stripe.Event;
} }
expect.extend({ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId: string }) {
toHaveEmail( let webhookResponse = null;
testEmail: ReturnType<Fixtures["emails"]["get"]>[number], try {
expectedEmail: { await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }));
//TODO: Support email HTML parsing to target specific elements } catch (e) {
htmlToContain?: string; if (!(e instanceof HttpError)) {
to: string; logger.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
} else {
logger.error("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
} }
) { webhookResponse = e as HttpError;
let isHtmlContained = true; }
let isToAddressExpected = true; return { webhookResponse };
if (expectedEmail.htmlToContain) { }
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
}
isToAddressExpected = expectedEmail.to === testEmail.to;
return {
pass: isHtmlContained && isToAddressExpected,
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't contained in ${testEmail.to}`;
},
};
},
});

View File

@ -0,0 +1,481 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Booking, BookingReference } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client";
import { expect } from "vitest";
import "vitest-fetch-mock";
import logger from "@calcom/lib/logger";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
import type { InputEventType } from "./bookingScenario";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveEmail(expectedEmail: { htmlToContain?: string; to: string }, to: string): R;
}
}
}
expect.extend({
toHaveEmail(
emails: Fixtures["emails"],
expectedEmail: {
//TODO: Support email HTML parsing to target specific elements
htmlToContain?: string;
to: string;
},
to: string
) {
const testEmail = emails.get().find((email) => email.to.includes(to));
if (!testEmail) {
return {
pass: false,
message: () => `No email sent to ${to}`,
};
}
let isHtmlContained = true;
let isToAddressExpected = true;
if (expectedEmail.htmlToContain) {
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
}
isToAddressExpected = expectedEmail.to === testEmail.to;
return {
pass: isHtmlContained && isToAddressExpected,
message: () => {
if (!isHtmlContained) {
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
}
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't equal to ${testEmail.to}`;
},
};
},
});
export function expectWebhookToHaveBeenCalledWith(
subscriberUrl: string,
data: {
triggerEvent: WebhookTriggerEvents;
payload: Record<string, unknown> | null;
}
) {
const fetchCalls = fetchMock.mock.calls;
const webhooksToSubscriberUrl = fetchCalls.filter((call) => {
return call[0] === subscriberUrl;
});
logger.silly("Scanning fetchCalls for webhook", fetchCalls);
const webhookFetchCall = webhooksToSubscriberUrl.find((call) => {
const body = call[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
return parsedBody.triggerEvent === data.triggerEvent;
});
if (!webhookFetchCall) {
throw new Error(
`Webhook not sent to ${subscriberUrl} for ${data.triggerEvent}. All webhooks: ${JSON.stringify(
webhooksToSubscriberUrl
)}`
);
}
expect(webhookFetchCall[0]).toBe(subscriberUrl);
const body = webhookFetchCall[1]?.body;
const parsedBody = JSON.parse((body as string) || "{}");
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
if (parsedBody.payload.metadata?.videoCallUrl) {
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
: parsedBody.payload.metadata.videoCallUrl;
}
if (data.payload) {
if (data.payload.metadata !== undefined) {
expect(parsedBody.payload.metadata).toEqual(expect.objectContaining(data.payload.metadata));
}
if (data.payload.responses !== undefined)
expect(parsedBody.payload.responses).toEqual(expect.objectContaining(data.payload.responses));
const { responses: _1, metadata: _2, ...remainingPayload } = data.payload;
expect(parsedBody.payload).toEqual(expect.objectContaining(remainingPayload));
}
}
export function expectWorkflowToBeTriggered() {
// TODO: Implement this.
}
export async function expectBookingToBeInDatabase(
booking: Partial<Booking> & Pick<Booking, "uid"> & { references?: Partial<BookingReference>[] }
) {
const actualBooking = await prismaMock.booking.findUnique({
where: {
uid: booking.uid,
},
include: {
references: true,
},
});
const { references, ...remainingBooking } = booking;
expect(actualBooking).toEqual(expect.objectContaining(remainingBooking));
expect(actualBooking?.references).toEqual(
expect.arrayContaining((references || []).map((reference) => expect.objectContaining(reference)))
);
}
export function expectSuccessfulBookingCreationEmails({
emails,
organizer,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
to: `${organizer.email}`,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
to: `${booker.name} <${booker.email}>`,
},
`${booker.name} <${booker.email}>`
);
}
export function expectSuccessfulBookingRescheduledEmails({
emails,
organizer,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
to: `${organizer.email}`,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
to: `${booker.name} <${booker.email}>`,
},
`${booker.name} <${booker.email}>`
);
}
export function expectAwaitingPaymentEmails({
emails,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>awaiting_payment_subject</title>",
to: `${booker.name} <${booker.email}>`,
},
`${booker.email}`
);
}
export function expectBookingRequestedEmails({
emails,
organizer,
booker,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
}) {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>event_awaiting_approval_subject</title>",
to: `${organizer.email}`,
},
`${organizer.email}`
);
expect(emails).toHaveEmail(
{
htmlToContain: "<title>booking_submitted_subject</title>",
to: `${booker.email}`,
},
`${booker.email}`
);
}
export function expectBookingRequestedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paidEvent,
eventType,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
eventType: InputEventType;
}) {
// There is an inconsistency in the way we send the data to the webhook for paid events and unpaid events. Fix that and then remove this if statement.
if (!paidEvent) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_REQUESTED",
payload: {
eventTitle: eventType.title,
eventDescription: eventType.description,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
} else {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_REQUESTED",
payload: {
eventTitle: eventType.title,
eventDescription: eventType.description,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "name", value: booker.name },
email: { label: "email", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
}
export function expectBookingCreatedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paidEvent,
videoCallUrl,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
videoCallUrl?: string;
}) {
if (!paidEvent) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_CREATED",
payload: {
metadata: {
...(videoCallUrl ? { videoCallUrl } : null),
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
} else {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_CREATED",
payload: {
// FIXME: File this bug and link ticket here. This is a bug in the code. metadata must be sent here like other BOOKING_CREATED webhook
metadata: null,
responses: {
name: { label: "name", value: booker.name },
email: { label: "email", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
}
export function expectBookingRescheduledWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
videoCallUrl,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
videoCallUrl?: string;
}) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
metadata: {
...(videoCallUrl ? { videoCallUrl } : null),
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
export function expectBookingPaymentIntiatedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paymentId,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string };
subscriberUrl: string;
location: string;
paymentId: number;
}) {
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: "BOOKING_PAYMENT_INITIATED",
payload: {
paymentId: paymentId,
metadata: {
// In a Pending Booking Request, we don't send the video call url
},
responses: {
name: { label: "your_name", value: booker.name },
email: { label: "email_address", value: booker.email },
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
export function expectSuccessfulCalendarEventCreationInCalendar(
calendarMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEventCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEventCalls: any[];
},
expected: {
externalCalendarId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
uid: string;
}
) {
expect(calendarMock.createEventCalls.length).toBe(1);
const call = calendarMock.createEventCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulCalendarEventUpdationInCalendar(
calendarMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEventCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEventCalls: any[];
},
expected: {
externalCalendarId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
uid: string;
}
) {
expect(calendarMock.updateEventCalls.length).toBe(1);
const call = calendarMock.updateEventCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulVideoMeetingCreationInCalendar(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeetingCalls: any[];
},
expected: {
externalCalendarId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
uid: string;
}
) {
expect(videoMock.createMeetingCalls.length).toBe(1);
const call = videoMock.createMeetingCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulVideoMeetingUpdationInCalendar(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeetingCalls: any[];
},
expected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bookingRef: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
}
) {
expect(videoMock.updateMeetingCalls.length).toBe(1);
const call = videoMock.updateMeetingCalls[0];
const bookingRef = call[0];
const calendarEvent = call[1];
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
}

View File

@ -88,6 +88,7 @@
"lint-staged": "^12.5.0", "lint-staged": "^12.5.0",
"mailhog": "^4.16.0", "mailhog": "^4.16.0",
"prettier": "^2.8.6", "prettier": "^2.8.6",
"prismock": "^1.21.1",
"tsc-absolute": "^1.0.0", "tsc-absolute": "^1.0.0",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"vitest": "^0.34.3", "vitest": "^0.34.3",

View File

@ -1,4 +1,4 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma"; import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";

View File

@ -260,6 +260,9 @@ export const createEvent = async (
return undefined; return undefined;
}) })
: undefined; : undefined;
if (!creationResult) {
logger.silly("createEvent failed", { success, uid, creationResult, originalEvent: calEvent, calError });
}
return { return {
appName: credential.appId || "", appName: credential.appId || "",

View File

@ -80,6 +80,7 @@ export default class EventManager {
* @param user * @param user
*/ */
constructor(user: EventManagerUser) { constructor(user: EventManagerUser) {
logger.silly("Initializing EventManager", JSON.stringify({ user }));
const appCredentials = getApps(user.credentials, true).flatMap((app) => const appCredentials = getApps(user.credentials, true).flatMap((app) =>
app.credentials.map((creds) => ({ ...creds, appName: app.name })) app.credentials.map((creds) => ({ ...creds, appName: app.name }))
); );
@ -312,7 +313,6 @@ export default class EventManager {
}, },
}); });
} }
return { return {
results, results,
referencesToCreate: [...booking.references], referencesToCreate: [...booking.references],
@ -361,6 +361,7 @@ export default class EventManager {
[] as DestinationCalendar[] [] as DestinationCalendar[]
); );
for (const destination of destinationCalendars) { for (const destination of destinationCalendars) {
logger.silly("Creating Calendar event", JSON.stringify({ destination }));
if (destination.credentialId) { if (destination.credentialId) {
let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId); let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
if (!credential) { if (!credential) {
@ -400,13 +401,21 @@ export default class EventManager {
} }
} }
} else { } else {
logger.silly(
"No destination Calendar found, falling back to first connected calendar",
JSON.stringify({
calendarCredentials: this.calendarCredentials,
})
);
/** /**
* Not ideal but, if we don't find a destination calendar, * Not ideal but, if we don't find a destination calendar,
* fallback to the first connected calendar * fallback to the first connected calendar - Shouldn't be a CRM calendar
*/ */
const [credential] = this.calendarCredentials.filter((cred) => cred.type === "calendar"); const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar"));
if (credential) { if (credential) {
const createdEvent = await createEvent(credential, event); const createdEvent = await createEvent(credential, event);
logger.silly("Created Calendar event", { createdEvent });
if (createdEvent) { if (createdEvent) {
createdEvents.push(createdEvent); createdEvents.push(createdEvent);
} }
@ -503,6 +512,7 @@ export default class EventManager {
): Promise<Array<EventResult<NewCalendarEventType>>> { ): Promise<Array<EventResult<NewCalendarEventType>>> {
let calendarReference: PartialReference[] | undefined = undefined, let calendarReference: PartialReference[] | undefined = undefined,
credential; credential;
logger.silly("updateAllCalendarEvents", JSON.stringify({ event, booking, newBookingId }));
try { try {
// If a newBookingId is given, update that calendar event // If a newBookingId is given, update that calendar event
let newBooking; let newBooking;
@ -564,6 +574,7 @@ export default class EventManager {
(credential) => credential.type === reference?.type (credential) => credential.type === reference?.type
); );
for (const credential of credentials) { for (const credential of credentials) {
logger.silly("updateAllCalendarEvents-credential", JSON.stringify({ credentials }));
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId)); result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
} }
} }

View File

@ -24,6 +24,7 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
for (const cred of withCredentials) { for (const cred of withCredentials) {
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`; const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
logger.silly("getVideoAdapters", JSON.stringify({ appName, cred }));
const appImportFn = appStore[appName as keyof typeof appStore]; const appImportFn = appStore[appName as keyof typeof appStore];
// Static Link Video Apps don't exist in packages/app-store/index.ts(it's manually maintained at the moment) and they aren't needed there anyway. // Static Link Video Apps don't exist in packages/app-store/index.ts(it's manually maintained at the moment) and they aren't needed there anyway.
@ -38,6 +39,8 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory; const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
const videoAdapter = makeVideoApiAdapter(cred); const videoAdapter = makeVideoApiAdapter(cred);
videoAdapters.push(videoAdapter); videoAdapters.push(videoAdapter);
} else {
log.error(`App ${appName} doesn't have 'lib.VideoApiAdapter' defined`);
} }
} }
@ -51,7 +54,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) =>
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => { const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
const uid: string = getUid(calEvent); const uid: string = getUid(calEvent);
log.silly("videoClient:createMeeting", JSON.stringify({ credential, uid, calEvent }));
if (!credential || !credential.appId) { if (!credential || !credential.appId) {
throw new Error( throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set." "Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
@ -116,21 +119,23 @@ const updateMeeting = async (
bookingRef: PartialReference | null bookingRef: PartialReference | null
): Promise<EventResult<VideoCallData>> => { ): Promise<EventResult<VideoCallData>> => {
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
let success = true; let success = true;
const [firstVideoAdapter] = await getVideoAdapters([credential]); const [firstVideoAdapter] = await getVideoAdapters([credential]);
const updatedMeeting = const canCallUpdateMeeting = !!(credential && bookingRef);
credential && bookingRef const updatedMeeting = canCallUpdateMeeting
? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => { ? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => {
await sendBrokenIntegrationEmail(calEvent, "video"); await sendBrokenIntegrationEmail(calEvent, "video");
log.error("updateMeeting failed", e, calEvent); log.error("updateMeeting failed", e, calEvent);
success = false; success = false;
return undefined; return undefined;
}) })
: undefined; : undefined;
if (!updatedMeeting) { if (!updatedMeeting) {
log.error(
"updateMeeting failed",
JSON.stringify({ bookingRef, canCallUpdateMeeting, calEvent, credential })
);
return { return {
appName: credential.appId || "", appName: credential.appId || "",
type: credential.type, type: credential.type,

View File

@ -0,0 +1,36 @@
import type { z } from "zod";
import dayjs from "@calcom/dayjs";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
/**
* Determines if a booking actually requires confirmation(considering requiresConfirmationThreshold)
*/
export const doesBookingRequireConfirmation = ({
booking: { startTime, eventType },
}: {
booking: {
startTime: Date;
eventType: {
requiresConfirmation?: boolean;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
} | null;
};
}) => {
let requiresConfirmation = eventType?.requiresConfirmation;
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
if (rcThreshold) {
// Convert startTime to UTC and create Day.js instances
const startTimeUTC = dayjs(startTime).utc();
const currentTime = dayjs();
// Calculate the time difference in the specified unit
const timeDifference = startTimeUTC.diff(currentTime, rcThreshold.unit);
// Check if the time difference exceeds the threshold
if (timeDifference > rcThreshold.time) {
requiresConfirmation = false;
}
}
return requiresConfirmation;
};

View File

@ -0,0 +1,37 @@
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import type { CalendarEvent } from "@calcom/types/Calendar";
export const getWebhookPayloadForBooking = ({
booking,
evt,
}: {
booking: {
eventType: {
title: string;
description: string | null;
requiresConfirmation: boolean;
price: number;
currency: string;
length: number;
id: number;
} | null;
id: number;
eventTypeId: number | null;
userId: number | null;
};
evt: CalendarEvent;
}) => {
const eventTypeInfo: EventTypeInfo = {
eventTitle: booking.eventType?.title,
eventDescription: booking.eventType?.description,
requiresConfirmation: booking.eventType?.requiresConfirmation || null,
price: booking.eventType?.price,
currency: booking.eventType?.currency,
length: booking.eventType?.length,
};
return {
...evt,
...eventTypeInfo,
bookingId: booking.id,
};
};

View File

@ -0,0 +1,69 @@
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getChildLogger({ prefix: ["[handleConfirmation] book:user"] });
/**
* Supposed to do whatever is needed when a booking is requested.
*/
export async function handleBookingRequested(args: {
evt: CalendarEvent;
booking: {
eventType: {
currency: string;
description: string | null;
id: number;
length: number;
price: number;
requiresConfirmation: boolean;
title: string;
teamId?: number | null;
} | null;
eventTypeId: number | null;
userId: number | null;
id: number;
};
}) {
const { evt, booking } = args;
await sendOrganizerRequestEmail({ ...evt });
await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]);
try {
const subscribersBookingRequested = await getWebhooks({
userId: booking.userId,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
teamId: booking.eventType?.teamId,
});
const webhookPayload = getWebhookPayloadForBooking({
booking,
evt,
});
const promises = subscribersBookingRequested.map((sub) =>
sendPayload(
sub.secret,
WebhookTriggerEvents.BOOKING_REQUESTED,
new Date().toISOString(),
sub,
webhookPayload
).catch((e) => {
console.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_REQUESTED}, URL: ${sub.subscriberUrl}`,
e
);
})
);
await Promise.all(promises);
} catch (error) {
// Silently fail
log.error(error);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1210,8 +1210,6 @@ async function handler(
teamId, teamId,
}; };
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
const handleSeats = async () => { const handleSeats = async () => {
let resultBooking: let resultBooking:
| (Partial<Booking> & { | (Partial<Booking> & {
@ -1219,6 +1217,7 @@ async function handler(
seatReferenceUid?: string; seatReferenceUid?: string;
paymentUid?: string; paymentUid?: string;
message?: string; message?: string;
paymentId?: number;
}) })
| null = null; | null = null;
@ -1762,6 +1761,7 @@ async function handler(
resultBooking = { ...foundBooking }; resultBooking = { ...foundBooking };
resultBooking["message"] = "Payment required"; resultBooking["message"] = "Payment required";
resultBooking["paymentUid"] = payment?.uid; resultBooking["paymentUid"] = payment?.uid;
resultBooking["id"] = payment?.id;
} else { } else {
resultBooking = { ...foundBooking }; resultBooking = { ...foundBooking };
} }
@ -2082,7 +2082,9 @@ async function handler(
} }
let videoCallUrl; let videoCallUrl;
if (originalRescheduledBooking?.uid) { if (originalRescheduledBooking?.uid) {
log.silly("Rescheduling booking", originalRescheduledBooking.uid);
try { try {
// cancel workflow reminders from previous rescheduled booking // cancel workflow reminders from previous rescheduled booking
await cancelWorkflowReminders(originalRescheduledBooking.workflowReminders); await cancelWorkflowReminders(originalRescheduledBooking.workflowReminders);
@ -2288,6 +2290,27 @@ async function handler(
await sendOrganizerRequestEmail({ ...evt, additionalNotes }); await sendOrganizerRequestEmail({ ...evt, additionalNotes });
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]); await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
} }
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
}
: undefined;
const webhookData = {
...evt,
...eventTypeInfo,
bookingId: booking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBody.metadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
};
if (bookingRequiresPayment) { if (bookingRequiresPayment) {
// Load credentials.app.categories // Load credentials.app.categories
@ -2329,9 +2352,23 @@ async function handler(
fullName, fullName,
bookerEmail bookerEmail
); );
const subscriberOptionsPaymentInitiated: GetSubscriberOptions = {
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
teamId,
};
await handleWebhookTrigger({
subscriberOptions: subscriberOptionsPaymentInitiated,
eventTrigger: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
webhookData: {
...webhookData,
paymentId: payment?.id,
},
});
req.statusCode = 201; req.statusCode = 201;
return { ...booking, message: "Payment required", paymentUid: payment?.uid }; return { ...booking, message: "Payment required", paymentUid: payment?.uid, paymentId: payment?.id };
} }
loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`); loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`);
@ -2340,28 +2377,6 @@ async function handler(
videoCallUrl = booking.location; videoCallUrl = booking.location;
} }
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
}
: undefined;
const webhookData = {
...evt,
...eventTypeInfo,
bookingId: booking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBody.metadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
};
if (isConfirmedByDefault) { if (isConfirmedByDefault) {
try { try {
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded); const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);

View File

@ -5,21 +5,19 @@ import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server"; import stripe from "@calcom/app-store/stripepayment/lib/server";
import EventManager from "@calcom/core/EventManager"; import EventManager from "@calcom/core/EventManager";
import dayjs from "@calcom/dayjs"; import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
import { sendOrganizerRequestEmail, sendAttendeeRequestEmail } from "@calcom/emails"; import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { IS_PRODUCTION } from "@calcom/lib/constants"; import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error"; import { HttpError as HttpCode } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { getBooking } from "@calcom/lib/payment/getBooking";
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess"; import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; const log = logger.getChildLogger({ prefix: ["[paymentWebhook]"] });
import type { CalendarEvent } from "@calcom/types/Calendar";
export const config = { export const config = {
api: { api: {
@ -27,109 +25,7 @@ export const config = {
}, },
}; };
async function getEventType(id: number) { export async function handleStripePaymentSuccess(event: Stripe.Event) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
recurringEvent: true,
requiresConfirmation: true,
metadata: true,
},
});
}
async function getBooking(bookingId: number) {
const booking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
user: {
select: {
id: true,
username: true,
timeZone: true,
timeFormat: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const eventType = { ...eventTypeRaw, metadata: EventTypeMetaDataSchema.parse(eventTypeRaw?.metadata) };
const { user } = booking;
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const t = await getTranslation(user.locale ?? "en", "common");
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 selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: t, locale: user.locale ?? "en" },
id: user.id,
},
attendees: attendeesList,
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
};
return {
booking,
user,
evt,
eventType,
};
}
async function handleStripePaymentSuccess(event: Stripe.Event) {
const paymentIntent = event.data.object as Stripe.PaymentIntent; const paymentIntent = event.data.object as Stripe.PaymentIntent;
const payment = await prisma.payment.findFirst({ const payment = await prisma.payment.findFirst({
where: { where: {
@ -140,8 +36,10 @@ async function handleStripePaymentSuccess(event: Stripe.Event) {
bookingId: true, bookingId: true,
}, },
}); });
if (!payment?.bookingId) { if (!payment?.bookingId) {
console.log(JSON.stringify(paymentIntent), JSON.stringify(payment)); log.error(JSON.stringify(paymentIntent), JSON.stringify(payment));
throw new HttpCode({ statusCode: 204, message: "Payment not found" });
} }
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" }); if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
@ -164,34 +62,16 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
paid: true, paid: true,
}; };
const userWithCredentials = await prisma.user.findUnique({ if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
where: {
id: user.id, const requiresConfirmation = doesBookingRequireConfirmation({
}, booking: {
select: { ...booking,
id: true, eventType,
username: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
credentials: { select: credentialForCalendarServiceSelect },
}, },
}); });
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
let requiresConfirmation = eventType?.requiresConfirmation;
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
if (rcThreshold) {
if (dayjs(dayjs(booking.startTime).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
requiresConfirmation = false;
}
}
if (!requiresConfirmation) { if (!requiresConfirmation) {
const eventManager = new EventManager(userWithCredentials); const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt); const scheduleResult = await eventManager.create(evt);
bookingData.references = { create: scheduleResult.referencesToCreate }; bookingData.references = { create: scheduleResult.referencesToCreate };
bookingData.status = BookingStatus.ACCEPTED; bookingData.status = BookingStatus.ACCEPTED;
@ -218,7 +98,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
if (!requiresConfirmation) { if (!requiresConfirmation) {
await handleConfirmation({ await handleConfirmation({
user: userWithCredentials, user,
evt, evt,
prisma, prisma,
bookingId: booking.id, bookingId: booking.id,

View File

@ -35,6 +35,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
{ value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" }, { value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" },
{ value: WebhookTriggerEvents.BOOKING_REJECTED, label: "booking_rejected" }, { value: WebhookTriggerEvents.BOOKING_REJECTED, label: "booking_rejected" },
{ value: WebhookTriggerEvents.BOOKING_REQUESTED, label: "booking_requested" }, { value: WebhookTriggerEvents.BOOKING_REQUESTED, label: "booking_requested" },
{ value: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, label: "booking_payment_initiated" },
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" }, { value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
{ value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" }, { value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" },
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" }, { value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },

View File

@ -8,6 +8,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.BOOKING_CREATED, WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED, WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.BOOKING_PAID, WebhookTriggerEvents.BOOKING_PAID,
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
WebhookTriggerEvents.MEETING_ENDED, WebhookTriggerEvents.MEETING_ENDED,
WebhookTriggerEvents.BOOKING_REQUESTED, WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_REJECTED, WebhookTriggerEvents.BOOKING_REJECTED,

View File

@ -0,0 +1,118 @@
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
async function getEventType(id: number) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
id: true,
recurringEvent: true,
requiresConfirmation: true,
metadata: true,
},
});
}
export async function getBooking(bookingId: number) {
const booking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
responses: true,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
user: {
select: {
id: true,
username: true,
timeZone: true,
credentials: { select: credentialForCalendarServiceSelect },
timeFormat: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const eventType = { ...eventTypeRaw, metadata: EventTypeMetaDataSchema.parse(eventTypeRaw?.metadata) };
const { user } = booking;
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const t = await getTranslation(user.locale ?? "en", "common");
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 selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
booking: booking,
bookingFields: booking.eventType?.bookingFields || null,
}),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: t, locale: user.locale ?? "en" },
id: user.id,
},
attendees: attendeesList,
location: booking.location,
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
};
return {
booking,
user,
evt,
eventType,
};
}

View File

@ -2,114 +2,19 @@ import type { Prisma } from "@prisma/client";
import EventManager from "@calcom/core/EventManager"; import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails"; import { sendScheduledEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { HttpError as HttpCode } from "@calcom/lib/http-error"; import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getBooking } from "@calcom/lib/payment/getBooking";
import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getTimeFormatStringFromUserTimeFormat } from "../timeFormat"; import logger from "../logger";
const log = logger.getChildLogger({ prefix: ["[handlePaymentSuccess]"] });
export async function handlePaymentSuccess(paymentId: number, bookingId: number) { export async function handlePaymentSuccess(paymentId: number, bookingId: number) {
const booking = await prisma.booking.findUnique({ const { booking, user: userWithCredentials, evt, eventType } = await getBooking(bookingId);
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
responses: true,
user: {
select: {
id: true,
username: true,
credentials: { select: credentialForCalendarServiceSelect },
timeZone: true,
timeFormat: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
payment: {
select: {
amount: true,
currency: true,
paymentOption: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const { user: userWithCredentials } = booking;
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
const { credentials, ...user } = userWithCredentials;
const t = await getTranslation(user.locale ?? "en", "common");
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 selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
booking: booking,
bookingFields: booking.eventType?.bookingFields || null,
}),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: t, locale: user.locale ?? "en" },
},
attendees: attendeesList,
location: booking.location,
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
paymentInfo: booking.payment?.[0] && {
amount: booking.payment[0].amount,
currency: booking.payment[0].currency,
paymentOption: booking.payment[0].paymentOption,
},
};
if (booking.location) evt.location = booking.location; if (booking.location) evt.location = booking.location;
@ -125,10 +30,16 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
bookingData.references = { create: scheduleResult.referencesToCreate }; bookingData.references = { create: scheduleResult.referencesToCreate };
} }
if (eventTypeRaw?.requiresConfirmation) { const requiresConfirmation = doesBookingRequireConfirmation({
booking: {
...booking,
eventType,
},
});
if (requiresConfirmation) {
delete bookingData.status; delete bookingData.status;
} }
const paymentUpdate = prisma.payment.update({ const paymentUpdate = prisma.payment.update({
where: { where: {
id: paymentId, id: paymentId,
@ -146,16 +57,23 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
}); });
await prisma.$transaction([paymentUpdate, bookingUpdate]); await prisma.$transaction([paymentUpdate, bookingUpdate]);
if (!isConfirmed) {
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) { if (!requiresConfirmation) {
await handleConfirmation({ await handleConfirmation({
user: userWithCredentials, user: userWithCredentials,
evt, evt,
prisma, prisma,
bookingId: booking.id, bookingId: booking.id,
booking, booking,
paid: true, paid: true,
}); });
} else {
await handleBookingRequested({
evt,
booking,
});
log.debug(`handling booking request for eventId ${eventType.id}`);
}
} else { } else {
await sendScheduledEmails({ ...evt }); await sendScheduledEmails({ ...evt });
} }
@ -165,15 +83,3 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
message: `Booking with id '${booking.id}' was paid and confirmed.`, message: `Booking with id '${booking.id}' was paid and confirmed.`,
}); });
} }
async function getEventType(id: number) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
recurringEvent: true,
requiresConfirmation: true,
},
});
}

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_PAYMENT_INITIATED';

View File

@ -541,6 +541,7 @@ enum PaymentOption {
enum WebhookTriggerEvents { enum WebhookTriggerEvents {
BOOKING_CREATED BOOKING_CREATED
BOOKING_PAYMENT_INITIATED
BOOKING_PAID BOOKING_PAID
BOOKING_RESCHEDULED BOOKING_RESCHEDULED
BOOKING_REQUESTED BOOKING_REQUESTED

View File

@ -1,18 +1,90 @@
import { PrismockClient } from "prismock";
import { beforeEach, vi } from "vitest"; import { beforeEach, vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import type { PrismaClient } from "@calcom/prisma"; import logger from "@calcom/lib/logger";
import * as selects from "@calcom/prisma/selects";
vi.mock("@calcom/prisma", () => ({ vi.mock("@calcom/prisma", () => ({
default: prisma, default: prisma,
prisma, prisma,
availabilityUserSelect: vi.fn(), ...selects,
userSelect: vi.fn(),
})); }));
const handlePrismockBugs = () => {
const __updateBooking = prismock.booking.update;
const __findManyWebhook = prismock.webhook.findMany;
const __findManyBooking = prismock.booking.findMany;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismock.booking.update = (...rest: any[]) => {
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
// In booking flows, we encounter such scenario, so let's fix that here directly till it's fixed in prismock
if (rest[0].data.references?.createMany) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
rest[0].data.references.createMany = rest[0].data.references?.createMany.data;
logger.silly("Fixed Prismock bug");
}
if (rest[0].data.references?.create) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
rest[0].data.references.create = rest[0].data.references?.create.data;
logger.silly("Fixed Prismock bug-1");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return __updateBooking(...rest);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismock.webhook.findMany = (...rest: any[]) => {
// There is some bug in prismock where it can't handle complex where clauses
if (rest[0].where?.OR && rest[0].where.AND) {
rest[0].where = undefined;
logger.silly("Fixed Prismock bug-2");
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return __findManyWebhook(...rest);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prismock.booking.findMany = (...rest: any[]) => {
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
// In booking flows, we encounter such scenario, so let's fix that here directly till it's fixed in prismock
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const where = rest[0]?.where;
if (where?.OR) {
logger.silly("Fixed Prismock bug-3");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where.OR.forEach((or: any) => {
if (or.startTime?.gte) {
or.startTime.gte = or.startTime.gte.toISOString ? or.startTime.gte.toISOString() : or.startTime.gte;
}
if (or.startTime?.lte) {
or.startTime.lte = or.startTime.lte.toISOString ? or.startTime.lte.toISOString() : or.startTime.lte;
}
if (or.endTime?.gte) {
or.endTime.lte = or.endTime.gte.toISOString ? or.endTime.gte.toISOString() : or.endTime.gte;
}
if (or.endTime?.lte) {
or.endTime.lte = or.endTime.lte.toISOString ? or.endTime.lte.toISOString() : or.endTime.lte;
}
});
}
return __findManyBooking(...rest);
};
};
beforeEach(() => { beforeEach(() => {
mockReset(prisma); // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismock.reset();
handlePrismockBugs();
}); });
const prisma = mockDeep<PrismaClient>(); const prismock = new PrismockClient();
const prisma = prismock;
export default prisma; export default prisma;

View File

@ -0,0 +1,18 @@
import { beforeEach, vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import type { PrismaClient } from "@calcom/prisma";
vi.mock("@calcom/prisma", () => ({
default: prisma,
prisma,
availabilityUserSelect: vi.fn(),
userSelect: vi.fn(),
}));
beforeEach(() => {
mockReset(prisma);
});
const prisma = mockDeep<PrismaClient>();
export default prisma;

1673
yarn.lock

File diff suppressed because it is too large Load Diff