fix: Handle payment flow webhooks in case of event requiring confirmation (#11458)
Co-authored-by: alannnc <alannnc@gmail.com>pull/11606/head^2
parent
0bb99fc667
commit
20898e1505
|
@ -423,6 +423,7 @@
|
|||
"booking_created": "Booking Created",
|
||||
"booking_rejected": "Booking Rejected",
|
||||
"booking_requested": "Booking Requested",
|
||||
"booking_payment_initiated": "Booking Payment Initiated",
|
||||
"meeting_ended": "Meeting Ended",
|
||||
"form_submitted": "Form Submitted",
|
||||
"booking_paid": "Booking Paid",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
||||
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
||||
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
|
|
|
@ -1,20 +1,18 @@
|
|||
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 { describe, expect, vi, beforeEach, afterEach, test } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
import { getDate, getGoogleCalendarCredential, createBookingScenario } from "../utils/bookingScenario";
|
||||
|
||||
// TODO: Mock properly
|
||||
prismaMock.eventType.findUnique.mockResolvedValue(null);
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prismaMock.user.findMany.mockResolvedValue([]);
|
||||
import {
|
||||
getDate,
|
||||
getGoogleCalendarCredential,
|
||||
createBookingScenario,
|
||||
} from "../utils/bookingScenario/bookingScenario";
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
IS_PRODUCTION: true,
|
||||
|
@ -146,13 +144,13 @@ const TestData = {
|
|||
};
|
||||
|
||||
const cleanup = async () => {
|
||||
await prisma.eventType.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.schedule.deleteMany();
|
||||
await prisma.selectedCalendar.deleteMany();
|
||||
await prisma.credential.deleteMany();
|
||||
await prisma.booking.deleteMany();
|
||||
await prisma.app.deleteMany();
|
||||
await prismock.eventType.deleteMany();
|
||||
await prismock.user.deleteMany();
|
||||
await prismock.schedule.deleteMany();
|
||||
await prismock.selectedCalendar.deleteMany();
|
||||
await prismock.credential.deleteMany();
|
||||
await prismock.booking.deleteMany();
|
||||
await prismock.app.deleteMany();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
|
@ -201,7 +199,7 @@ describe("getSchedule", () => {
|
|||
apps: [TestData.apps.googleCalendar],
|
||||
};
|
||||
// An event with one accepted booking
|
||||
createBookingScenario(scenarioData);
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
|
||||
input: {
|
||||
|
@ -228,7 +226,7 @@ describe("getSchedule", () => {
|
|||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
// An event with one accepted booking
|
||||
createBookingScenario({
|
||||
await createBookingScenario({
|
||||
// An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours)
|
||||
eventTypes: [
|
||||
{
|
||||
|
@ -354,7 +352,7 @@ describe("getSchedule", () => {
|
|||
});
|
||||
|
||||
test("slots are available as per `length`, `slotInterval` of the event", async () => {
|
||||
createBookingScenario({
|
||||
await createBookingScenario({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
|
@ -453,7 +451,7 @@ describe("getSchedule", () => {
|
|||
})()
|
||||
);
|
||||
|
||||
createBookingScenario({
|
||||
await createBookingScenario({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
|
@ -569,7 +567,7 @@ describe("getSchedule", () => {
|
|||
apps: [TestData.apps.googleCalendar],
|
||||
};
|
||||
|
||||
createBookingScenario(scenarioData);
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
|
||||
input: {
|
||||
|
@ -643,7 +641,7 @@ describe("getSchedule", () => {
|
|||
apps: [TestData.apps.googleCalendar],
|
||||
};
|
||||
|
||||
createBookingScenario(scenarioData);
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const scheduleForEventOnADayWithCalBooking = await getSchedule({
|
||||
input: {
|
||||
|
@ -701,7 +699,7 @@ describe("getSchedule", () => {
|
|||
apps: [TestData.apps.googleCalendar],
|
||||
};
|
||||
|
||||
createBookingScenario(scenarioData);
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const schedule = await getSchedule({
|
||||
input: {
|
||||
|
@ -765,7 +763,7 @@ describe("getSchedule", () => {
|
|||
],
|
||||
};
|
||||
|
||||
createBookingScenario(scenarioData);
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const scheduleForEventOnADayWithDateOverride = await getSchedule({
|
||||
input: {
|
||||
|
@ -790,7 +788,7 @@ describe("getSchedule", () => {
|
|||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
|
||||
createBookingScenario({
|
||||
await createBookingScenario({
|
||||
eventTypes: [
|
||||
// A Collective Event Type hosted by this user
|
||||
{
|
||||
|
@ -885,7 +883,7 @@ describe("getSchedule", () => {
|
|||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
|
||||
createBookingScenario({
|
||||
await createBookingScenario({
|
||||
eventTypes: [
|
||||
// An event having two users with one accepted booking
|
||||
{
|
||||
|
@ -1010,7 +1008,7 @@ describe("getSchedule", () => {
|
|||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
createBookingScenario({
|
||||
await createBookingScenario({
|
||||
eventTypes: [
|
||||
// An event having two users with one accepted booking
|
||||
{
|
||||
|
|
|
@ -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 { describe, expect, it, vi } from "vitest";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
||||
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -1,25 +1,23 @@
|
|||
import appStoreMock from "../../../../tests/libs/__mocks__/app-store";
|
||||
import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n";
|
||||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
||||
import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
|
||||
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
|
||||
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 { WebhookTriggerEvents } from "@prisma/client";
|
||||
import type Stripe from "stripe";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { expect } from "vitest";
|
||||
import "vitest-fetch-mock";
|
||||
|
||||
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 type { SchedulingType } 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 { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
import { getMockPaymentService } from "./MockPaymentService";
|
||||
|
||||
type App = {
|
||||
slug: string;
|
||||
|
@ -78,7 +76,7 @@ type InputUser = typeof TestData.users.example & { id: number } & {
|
|||
}[];
|
||||
};
|
||||
|
||||
type InputEventType = {
|
||||
export type InputEventType = {
|
||||
id: number;
|
||||
title?: string;
|
||||
length?: number;
|
||||
|
@ -94,9 +92,11 @@ type InputEventType = {
|
|||
beforeEventBuffer?: number;
|
||||
afterEventBuffer?: number;
|
||||
requiresConfirmation?: boolean;
|
||||
};
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users">>;
|
||||
|
||||
type InputBooking = {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
userId?: number;
|
||||
eventTypeId: number;
|
||||
startTime: string;
|
||||
|
@ -104,14 +104,40 @@ type InputBooking = {
|
|||
title?: string;
|
||||
status: BookingStatus;
|
||||
attendees?: { email: string }[];
|
||||
references?: {
|
||||
type: string;
|
||||
uid: string;
|
||||
meetingId?: string;
|
||||
meetingPassword?: string;
|
||||
meetingUrl?: string;
|
||||
bookingId?: number;
|
||||
externalCalendarId?: string;
|
||||
deleted?: boolean;
|
||||
credentialId?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
const Timezones = {
|
||||
"+5:30": "Asia/Kolkata",
|
||||
"+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 = {
|
||||
title: "Base EventType Title",
|
||||
slug: "base-event-type-slug",
|
||||
|
@ -119,7 +145,7 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
|
|||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
schedulingType: null,
|
||||
|
||||
length: 15,
|
||||
//TODO: What is the purpose of periodStartDate and periodEndDate? Test these?
|
||||
periodStartDate: 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,
|
||||
};
|
||||
});
|
||||
|
||||
logger.silly("TestData: Creating EventType", eventTypes);
|
||||
// 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);
|
||||
logger.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
|
||||
await addEventTypesToDb(eventTypesWithUsers);
|
||||
}
|
||||
|
||||
async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[]) {
|
||||
logger.silly("TestData: Creating Bookings", bookings);
|
||||
|
||||
// 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[]
|
||||
);
|
||||
});
|
||||
function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
|
||||
prismock.bookingReference.createMany({
|
||||
data: bookingReferences,
|
||||
});
|
||||
}
|
||||
|
||||
async function addWebhooks(webhooks: InputWebhook[]) {
|
||||
prismaMock.webhook.findMany.mockResolvedValue(
|
||||
// @ts-expect-error Prisma v5 breaks this
|
||||
webhooks.map((webhook) => {
|
||||
return {
|
||||
...webhook,
|
||||
payloadTemplate: null,
|
||||
secret: null,
|
||||
id: uuidv4(),
|
||||
createdAt: new Date(),
|
||||
userId: webhook.userId || null,
|
||||
eventTypeId: webhook.eventTypeId || null,
|
||||
teamId: webhook.teamId || null,
|
||||
};
|
||||
async function addBookingsToDb(
|
||||
bookings: (Prisma.BookingCreateInput & {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
references: any[];
|
||||
})[]
|
||||
) {
|
||||
await prismock.booking.createMany({
|
||||
data: bookings,
|
||||
});
|
||||
logger.silly(
|
||||
"TestData: Booking as in DB",
|
||||
JSON.stringify({
|
||||
bookings: await prismock.booking.findMany({
|
||||
include: {
|
||||
references: true,
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function addUsers(users: InputUser[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prismaMock.user.findUniqueOrThrow.mockImplementation((findUniqueArgs) => {
|
||||
return new Promise((resolve) => {
|
||||
// @ts-expect-error Prisma v5 breaks this
|
||||
resolve({
|
||||
// @ts-expect-error Prisma v5 breaks this
|
||||
email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`,
|
||||
} as unknown as PrismaUser);
|
||||
});
|
||||
async function addBookings(bookings: InputBooking[]) {
|
||||
logger.silly("TestData: Creating Bookings", JSON.stringify(bookings));
|
||||
const allBookings = [...bookings].map((booking) => {
|
||||
if (booking.references) {
|
||||
addBookingReferencesToDB(
|
||||
booking.references.map((reference) => {
|
||||
return {
|
||||
...reference,
|
||||
bookingId: booking.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return {
|
||||
uid: uuidv4(),
|
||||
workflowReminders: [],
|
||||
references: [],
|
||||
title: "Test Booking Title",
|
||||
...booking,
|
||||
};
|
||||
});
|
||||
|
||||
prismaMock.user.findMany.mockResolvedValue(
|
||||
// @ts-expect-error Prisma v5 breaks this
|
||||
users.map((user) => {
|
||||
return {
|
||||
...user,
|
||||
username: `IntegrationTestUser${user.id}`,
|
||||
email: `IntegrationTestUser${user.id}@example.com`,
|
||||
};
|
||||
}) as unknown as PrismaUser[]
|
||||
await addBookingsToDb(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
allBookings.map((booking) => {
|
||||
const bookingCreate = booking;
|
||||
if (booking.references) {
|
||||
bookingCreate.references = {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@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) {
|
||||
logger.silly("TestData: Creating Scenario", data);
|
||||
addUsers(data.users);
|
||||
logger.silly("TestData: Creating Scenario", JSON.stringify({ data }));
|
||||
await addUsers(data.users);
|
||||
|
||||
const eventType = addEventTypes(data.eventTypes, data.users);
|
||||
const eventType = await addEventTypes(data.eventTypes, data.users);
|
||||
if (data.apps) {
|
||||
// @ts-expect-error Prisma v5 breaks this
|
||||
prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]);
|
||||
// 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);
|
||||
prismock.app.createMany({
|
||||
data: data.apps,
|
||||
});
|
||||
}
|
||||
data.bookings = data.bookings || [];
|
||||
allowSuccessfulBookingCreation();
|
||||
addBookings(data.bookings, data.eventTypes);
|
||||
// allowSuccessfulBookingCreation();
|
||||
await addBookings(data.bookings);
|
||||
// mockBusyCalendarTimes([]);
|
||||
addWebhooks(data.webhooks || []);
|
||||
await addWebhooks(data.webhooks || []);
|
||||
// addPaymentMock();
|
||||
return {
|
||||
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.
|
||||
* We are not using DayJS because that's actually being tested by this code.
|
||||
|
@ -372,9 +390,11 @@ export function getMockedCredential({
|
|||
scope: string;
|
||||
};
|
||||
}) {
|
||||
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
|
||||
return {
|
||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||
appId: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].slug,
|
||||
type: app.type,
|
||||
appId: app.slug,
|
||||
app: app,
|
||||
key: {
|
||||
expiry_date: Date.now() + 1000000,
|
||||
token_type: "Bearer",
|
||||
|
@ -399,7 +419,16 @@ export function getZoomAppCredential() {
|
|||
return getMockedCredential({
|
||||
metadataLookupKey: "zoomvideo",
|
||||
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: {
|
||||
"google-calendar": {
|
||||
slug: "google-calendar",
|
||||
enabled: true,
|
||||
dirName: "whatever",
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
|
@ -479,6 +509,38 @@ export const TestData = {
|
|||
"daily-video": {
|
||||
slug: "daily-video",
|
||||
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
|
||||
//@ts-ignore
|
||||
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 {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
|
@ -540,6 +594,7 @@ export function getScenarioData({
|
|||
usersApartFromOrganizer = [],
|
||||
apps = [],
|
||||
webhooks,
|
||||
bookings,
|
||||
}: // hosts = [],
|
||||
{
|
||||
organizer: ReturnType<typeof getOrganizer>;
|
||||
|
@ -547,6 +602,7 @@ export function getScenarioData({
|
|||
apps: ScenarioData["apps"];
|
||||
usersApartFromOrganizer?: ScenarioData["users"];
|
||||
webhooks?: ScenarioData["webhooks"];
|
||||
bookings?: ScenarioData["bookings"];
|
||||
// hosts?: ScenarioData["hosts"];
|
||||
}) {
|
||||
const users = [organizer, ...usersApartFromOrganizer];
|
||||
|
@ -561,22 +617,28 @@ export function getScenarioData({
|
|||
});
|
||||
return {
|
||||
// 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,
|
||||
apps: [...apps],
|
||||
webhooks,
|
||||
bookings: bookings || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mockEnableEmailFeature() {
|
||||
// @ts-expect-error Prisma v5 breaks this
|
||||
prismaMock.feature.findMany.mockResolvedValue([
|
||||
{
|
||||
export function enableEmailFeature() {
|
||||
prismock.feature.create({
|
||||
data: {
|
||||
slug: "emails",
|
||||
// It's a kill switch
|
||||
enabled: false,
|
||||
type: "KILL_SWITCH",
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
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 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({
|
||||
lib: {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
CalendarService: function MockCalendarService() {
|
||||
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({
|
||||
type: "daily_video",
|
||||
id: "dailyEventName",
|
||||
password: "dailyvideopass",
|
||||
url: "http://dailyvideo.example.com",
|
||||
type: app.type,
|
||||
additionalInfo: {},
|
||||
uid: "PROBABLY_UNUSED_UID",
|
||||
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[]> => {
|
||||
|
@ -614,16 +730,37 @@ export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof ap
|
|||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
createEventCalls,
|
||||
updateEventCalls,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey,
|
||||
appStoreLookupKey,
|
||||
videoMeetingData,
|
||||
}: {
|
||||
metadataLookupKey: string;
|
||||
appStoreLookupKey?: string;
|
||||
videoMeetingData?: {
|
||||
password: string;
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
}) {
|
||||
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
|
||||
//@ts-ignore
|
||||
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
|
||||
//@ts-ignore
|
||||
VideoApiAdapter: () => ({
|
||||
createMeeting: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createMeeting: (...rest: any[]) => {
|
||||
createMeetingCalls.push(rest);
|
||||
return Promise.resolve({
|
||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||
id: "MOCK_ID",
|
||||
password: "MOCK_PASS",
|
||||
url: `http://mock-${metadataLookupKey}.example.com`,
|
||||
...videoMeetingData,
|
||||
});
|
||||
},
|
||||
// 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({
|
||||
|
@ -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 }) {
|
||||
return {
|
||||
name,
|
||||
|
@ -715,40 +869,28 @@ export function getBooker({ name, email }: { name: string; email: string }) {
|
|||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toHaveEmail(expectedEmail: { htmlToContain?: string; to: string }): R;
|
||||
}
|
||||
}
|
||||
export function getMockedStripePaymentEvent({ paymentIntentId }: { paymentIntentId: string }) {
|
||||
return {
|
||||
id: null,
|
||||
data: {
|
||||
object: {
|
||||
id: paymentIntentId,
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe.Event;
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toHaveEmail(
|
||||
testEmail: ReturnType<Fixtures["emails"]["get"]>[number],
|
||||
expectedEmail: {
|
||||
//TODO: Support email HTML parsing to target specific elements
|
||||
htmlToContain?: string;
|
||||
to: string;
|
||||
export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId: string }) {
|
||||
let webhookResponse = null;
|
||||
try {
|
||||
await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }));
|
||||
} catch (e) {
|
||||
if (!(e instanceof HttpError)) {
|
||||
logger.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
|
||||
} else {
|
||||
logger.error("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
|
||||
}
|
||||
) {
|
||||
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 contained in ${testEmail.to}`;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
webhookResponse = e as HttpError;
|
||||
}
|
||||
return { webhookResponse };
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -88,6 +88,7 @@
|
|||
"lint-staged": "^12.5.0",
|
||||
"mailhog": "^4.16.0",
|
||||
"prettier": "^2.8.6",
|
||||
"prismock": "^1.21.1",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^4.9.4",
|
||||
"vitest": "^0.34.3",
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -260,6 +260,9 @@ export const createEvent = async (
|
|||
return undefined;
|
||||
})
|
||||
: undefined;
|
||||
if (!creationResult) {
|
||||
logger.silly("createEvent failed", { success, uid, creationResult, originalEvent: calEvent, calError });
|
||||
}
|
||||
|
||||
return {
|
||||
appName: credential.appId || "",
|
||||
|
|
|
@ -80,6 +80,7 @@ export default class EventManager {
|
|||
* @param user
|
||||
*/
|
||||
constructor(user: EventManagerUser) {
|
||||
logger.silly("Initializing EventManager", JSON.stringify({ user }));
|
||||
const appCredentials = getApps(user.credentials, true).flatMap((app) =>
|
||||
app.credentials.map((creds) => ({ ...creds, appName: app.name }))
|
||||
);
|
||||
|
@ -312,7 +313,6 @@ export default class EventManager {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
referencesToCreate: [...booking.references],
|
||||
|
@ -361,6 +361,7 @@ export default class EventManager {
|
|||
[] as DestinationCalendar[]
|
||||
);
|
||||
for (const destination of destinationCalendars) {
|
||||
logger.silly("Creating Calendar event", JSON.stringify({ destination }));
|
||||
if (destination.credentialId) {
|
||||
let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
|
||||
if (!credential) {
|
||||
|
@ -400,13 +401,21 @@ export default class EventManager {
|
|||
}
|
||||
}
|
||||
} 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,
|
||||
* 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) {
|
||||
const createdEvent = await createEvent(credential, event);
|
||||
logger.silly("Created Calendar event", { createdEvent });
|
||||
if (createdEvent) {
|
||||
createdEvents.push(createdEvent);
|
||||
}
|
||||
|
@ -503,6 +512,7 @@ export default class EventManager {
|
|||
): Promise<Array<EventResult<NewCalendarEventType>>> {
|
||||
let calendarReference: PartialReference[] | undefined = undefined,
|
||||
credential;
|
||||
logger.silly("updateAllCalendarEvents", JSON.stringify({ event, booking, newBookingId }));
|
||||
try {
|
||||
// If a newBookingId is given, update that calendar event
|
||||
let newBooking;
|
||||
|
@ -564,6 +574,7 @@ export default class EventManager {
|
|||
(credential) => credential.type === reference?.type
|
||||
);
|
||||
for (const credential of credentials) {
|
||||
logger.silly("updateAllCalendarEvents-credential", JSON.stringify({ credentials }));
|
||||
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
|
|||
|
||||
for (const cred of withCredentials) {
|
||||
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];
|
||||
|
||||
// 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 videoAdapter = makeVideoApiAdapter(cred);
|
||||
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 uid: string = getUid(calEvent);
|
||||
|
||||
log.silly("videoClient:createMeeting", JSON.stringify({ credential, uid, calEvent }));
|
||||
if (!credential || !credential.appId) {
|
||||
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."
|
||||
|
@ -116,21 +119,23 @@ const updateMeeting = async (
|
|||
bookingRef: PartialReference | null
|
||||
): Promise<EventResult<VideoCallData>> => {
|
||||
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||
|
||||
let success = true;
|
||||
|
||||
const [firstVideoAdapter] = await getVideoAdapters([credential]);
|
||||
const updatedMeeting =
|
||||
credential && bookingRef
|
||||
? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => {
|
||||
await sendBrokenIntegrationEmail(calEvent, "video");
|
||||
log.error("updateMeeting failed", e, calEvent);
|
||||
success = false;
|
||||
return undefined;
|
||||
})
|
||||
: undefined;
|
||||
const canCallUpdateMeeting = !!(credential && bookingRef);
|
||||
const updatedMeeting = canCallUpdateMeeting
|
||||
? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => {
|
||||
await sendBrokenIntegrationEmail(calEvent, "video");
|
||||
log.error("updateMeeting failed", e, calEvent);
|
||||
success = false;
|
||||
return undefined;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!updatedMeeting) {
|
||||
log.error(
|
||||
"updateMeeting failed",
|
||||
JSON.stringify({ bookingRef, canCallUpdateMeeting, calEvent, credential })
|
||||
);
|
||||
return {
|
||||
appName: credential.appId || "",
|
||||
type: credential.type,
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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
|
@ -1210,8 +1210,6 @@ async function handler(
|
|||
teamId,
|
||||
};
|
||||
|
||||
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
||||
|
||||
const handleSeats = async () => {
|
||||
let resultBooking:
|
||||
| (Partial<Booking> & {
|
||||
|
@ -1219,6 +1217,7 @@ async function handler(
|
|||
seatReferenceUid?: string;
|
||||
paymentUid?: string;
|
||||
message?: string;
|
||||
paymentId?: number;
|
||||
})
|
||||
| null = null;
|
||||
|
||||
|
@ -1762,6 +1761,7 @@ async function handler(
|
|||
resultBooking = { ...foundBooking };
|
||||
resultBooking["message"] = "Payment required";
|
||||
resultBooking["paymentUid"] = payment?.uid;
|
||||
resultBooking["id"] = payment?.id;
|
||||
} else {
|
||||
resultBooking = { ...foundBooking };
|
||||
}
|
||||
|
@ -2082,7 +2082,9 @@ async function handler(
|
|||
}
|
||||
|
||||
let videoCallUrl;
|
||||
|
||||
if (originalRescheduledBooking?.uid) {
|
||||
log.silly("Rescheduling booking", originalRescheduledBooking.uid);
|
||||
try {
|
||||
// cancel workflow reminders from previous rescheduled booking
|
||||
await cancelWorkflowReminders(originalRescheduledBooking.workflowReminders);
|
||||
|
@ -2288,6 +2290,27 @@ async function handler(
|
|||
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
||||
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) {
|
||||
// Load credentials.app.categories
|
||||
|
@ -2329,9 +2352,23 @@ async function handler(
|
|||
fullName,
|
||||
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;
|
||||
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`);
|
||||
|
@ -2340,28 +2377,6 @@ async function handler(
|
|||
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) {
|
||||
try {
|
||||
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
||||
|
|
|
@ -5,21 +5,19 @@ import type Stripe from "stripe";
|
|||
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendOrganizerRequestEmail, sendAttendeeRequestEmail } from "@calcom/emails";
|
||||
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
|
||||
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
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 { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[paymentWebhook]"] });
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
@ -27,109 +25,7 @@ export const config = {
|
|||
},
|
||||
};
|
||||
|
||||
async function getEventType(id: number) {
|
||||
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) {
|
||||
export async function handleStripePaymentSuccess(event: Stripe.Event) {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
|
@ -140,8 +36,10 @@ async function handleStripePaymentSuccess(event: Stripe.Event) {
|
|||
bookingId: true,
|
||||
},
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
|
@ -164,34 +62,16 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
|
|||
paid: true,
|
||||
};
|
||||
|
||||
const userWithCredentials = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
credentials: { select: credentialForCalendarServiceSelect },
|
||||
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||
|
||||
const requiresConfirmation = doesBookingRequireConfirmation({
|
||||
booking: {
|
||||
...booking,
|
||||
eventType,
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
const eventManager = new EventManager(userWithCredentials);
|
||||
const eventManager = new EventManager(user);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
bookingData.status = BookingStatus.ACCEPTED;
|
||||
|
@ -218,7 +98,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
|
|||
|
||||
if (!requiresConfirmation) {
|
||||
await handleConfirmation({
|
||||
user: userWithCredentials,
|
||||
user,
|
||||
evt,
|
||||
prisma,
|
||||
bookingId: booking.id,
|
||||
|
|
|
@ -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_REJECTED, label: "booking_rejected" },
|
||||
{ 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_PAID, label: "booking_paid" },
|
||||
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
|
||||
|
|
|
@ -8,6 +8,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
|
|||
WebhookTriggerEvents.BOOKING_CREATED,
|
||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
WebhookTriggerEvents.BOOKING_PAID,
|
||||
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
||||
WebhookTriggerEvents.MEETING_ENDED,
|
||||
WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
WebhookTriggerEvents.BOOKING_REJECTED,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -2,114 +2,19 @@ import type { Prisma } from "@prisma/client";
|
|||
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
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 { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { getBooking } from "@calcom/lib/payment/getBooking";
|
||||
import prisma from "@calcom/prisma";
|
||||
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) {
|
||||
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,
|
||||
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,
|
||||
},
|
||||
};
|
||||
const { booking, user: userWithCredentials, evt, eventType } = await getBooking(bookingId);
|
||||
|
||||
if (booking.location) evt.location = booking.location;
|
||||
|
||||
|
@ -125,10 +30,16 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
|
|||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
}
|
||||
|
||||
if (eventTypeRaw?.requiresConfirmation) {
|
||||
const requiresConfirmation = doesBookingRequireConfirmation({
|
||||
booking: {
|
||||
...booking,
|
||||
eventType,
|
||||
},
|
||||
});
|
||||
|
||||
if (requiresConfirmation) {
|
||||
delete bookingData.status;
|
||||
}
|
||||
|
||||
const paymentUpdate = prisma.payment.update({
|
||||
where: {
|
||||
id: paymentId,
|
||||
|
@ -146,16 +57,23 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
|
|||
});
|
||||
|
||||
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
||||
|
||||
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
|
||||
await handleConfirmation({
|
||||
user: userWithCredentials,
|
||||
evt,
|
||||
prisma,
|
||||
bookingId: booking.id,
|
||||
booking,
|
||||
paid: true,
|
||||
});
|
||||
if (!isConfirmed) {
|
||||
if (!requiresConfirmation) {
|
||||
await handleConfirmation({
|
||||
user: userWithCredentials,
|
||||
evt,
|
||||
prisma,
|
||||
bookingId: booking.id,
|
||||
booking,
|
||||
paid: true,
|
||||
});
|
||||
} else {
|
||||
await handleBookingRequested({
|
||||
evt,
|
||||
booking,
|
||||
});
|
||||
log.debug(`handling booking request for eventId ${eventType.id}`);
|
||||
}
|
||||
} else {
|
||||
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.`,
|
||||
});
|
||||
}
|
||||
|
||||
async function getEventType(id: number) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_PAYMENT_INITIATED';
|
|
@ -541,6 +541,7 @@ enum PaymentOption {
|
|||
|
||||
enum WebhookTriggerEvents {
|
||||
BOOKING_CREATED
|
||||
BOOKING_PAYMENT_INITIATED
|
||||
BOOKING_PAID
|
||||
BOOKING_RESCHEDULED
|
||||
BOOKING_REQUESTED
|
||||
|
|
|
@ -1,18 +1,90 @@
|
|||
import { PrismockClient } from "prismock";
|
||||
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", () => ({
|
||||
default: prisma,
|
||||
prisma,
|
||||
availabilityUserSelect: vi.fn(),
|
||||
userSelect: vi.fn(),
|
||||
...selects,
|
||||
}));
|
||||
|
||||
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(() => {
|
||||
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;
|
||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue