fix: Handle payment flow webhooks in case of event requiring confirmation (#11458)
Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
parent
0bb99fc667
commit
20898e1505
|
@ -423,6 +423,7 @@
|
||||||
"booking_created": "Booking Created",
|
"booking_created": "Booking Created",
|
||||||
"booking_rejected": "Booking Rejected",
|
"booking_rejected": "Booking Rejected",
|
||||||
"booking_requested": "Booking Requested",
|
"booking_requested": "Booking Requested",
|
||||||
|
"booking_payment_initiated": "Booking Payment Initiated",
|
||||||
"meeting_ended": "Meeting Ended",
|
"meeting_ended": "Meeting Ended",
|
||||||
"form_submitted": "Form Submitted",
|
"form_submitted": "Form Submitted",
|
||||||
"booking_paid": "Booking Paid",
|
"booking_paid": "Booking Paid",
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
|
|
@ -1,20 +1,18 @@
|
||||||
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
|
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
|
||||||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
import prismock from "../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
import { diff } from "jest-diff";
|
import { diff } from "jest-diff";
|
||||||
import { describe, expect, vi, beforeEach, afterEach, test } from "vitest";
|
import { describe, expect, vi, beforeEach, afterEach, test } from "vitest";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
|
||||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
|
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||||
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
|
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||||
|
|
||||||
import { getDate, getGoogleCalendarCredential, createBookingScenario } from "../utils/bookingScenario";
|
import {
|
||||||
|
getDate,
|
||||||
// TODO: Mock properly
|
getGoogleCalendarCredential,
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(null);
|
createBookingScenario,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
} from "../utils/bookingScenario/bookingScenario";
|
||||||
prismaMock.user.findMany.mockResolvedValue([]);
|
|
||||||
|
|
||||||
vi.mock("@calcom/lib/constants", () => ({
|
vi.mock("@calcom/lib/constants", () => ({
|
||||||
IS_PRODUCTION: true,
|
IS_PRODUCTION: true,
|
||||||
|
@ -146,13 +144,13 @@ const TestData = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await prisma.eventType.deleteMany();
|
await prismock.eventType.deleteMany();
|
||||||
await prisma.user.deleteMany();
|
await prismock.user.deleteMany();
|
||||||
await prisma.schedule.deleteMany();
|
await prismock.schedule.deleteMany();
|
||||||
await prisma.selectedCalendar.deleteMany();
|
await prismock.selectedCalendar.deleteMany();
|
||||||
await prisma.credential.deleteMany();
|
await prismock.credential.deleteMany();
|
||||||
await prisma.booking.deleteMany();
|
await prismock.booking.deleteMany();
|
||||||
await prisma.app.deleteMany();
|
await prismock.app.deleteMany();
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
@ -201,7 +199,7 @@ describe("getSchedule", () => {
|
||||||
apps: [TestData.apps.googleCalendar],
|
apps: [TestData.apps.googleCalendar],
|
||||||
};
|
};
|
||||||
// An event with one accepted booking
|
// An event with one accepted booking
|
||||||
createBookingScenario(scenarioData);
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
|
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
|
||||||
input: {
|
input: {
|
||||||
|
@ -228,7 +226,7 @@ describe("getSchedule", () => {
|
||||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||||
|
|
||||||
// An event with one accepted booking
|
// An event with one accepted booking
|
||||||
createBookingScenario({
|
await createBookingScenario({
|
||||||
// An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours)
|
// An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours)
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
{
|
{
|
||||||
|
@ -354,7 +352,7 @@ describe("getSchedule", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("slots are available as per `length`, `slotInterval` of the event", async () => {
|
test("slots are available as per `length`, `slotInterval` of the event", async () => {
|
||||||
createBookingScenario({
|
await createBookingScenario({
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -453,7 +451,7 @@ describe("getSchedule", () => {
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
|
|
||||||
createBookingScenario({
|
await createBookingScenario({
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -569,7 +567,7 @@ describe("getSchedule", () => {
|
||||||
apps: [TestData.apps.googleCalendar],
|
apps: [TestData.apps.googleCalendar],
|
||||||
};
|
};
|
||||||
|
|
||||||
createBookingScenario(scenarioData);
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
|
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
|
||||||
input: {
|
input: {
|
||||||
|
@ -643,7 +641,7 @@ describe("getSchedule", () => {
|
||||||
apps: [TestData.apps.googleCalendar],
|
apps: [TestData.apps.googleCalendar],
|
||||||
};
|
};
|
||||||
|
|
||||||
createBookingScenario(scenarioData);
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
const scheduleForEventOnADayWithCalBooking = await getSchedule({
|
const scheduleForEventOnADayWithCalBooking = await getSchedule({
|
||||||
input: {
|
input: {
|
||||||
|
@ -701,7 +699,7 @@ describe("getSchedule", () => {
|
||||||
apps: [TestData.apps.googleCalendar],
|
apps: [TestData.apps.googleCalendar],
|
||||||
};
|
};
|
||||||
|
|
||||||
createBookingScenario(scenarioData);
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
const schedule = await getSchedule({
|
const schedule = await getSchedule({
|
||||||
input: {
|
input: {
|
||||||
|
@ -765,7 +763,7 @@ describe("getSchedule", () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
createBookingScenario(scenarioData);
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
const scheduleForEventOnADayWithDateOverride = await getSchedule({
|
const scheduleForEventOnADayWithDateOverride = await getSchedule({
|
||||||
input: {
|
input: {
|
||||||
|
@ -790,7 +788,7 @@ describe("getSchedule", () => {
|
||||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||||
|
|
||||||
createBookingScenario({
|
await createBookingScenario({
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
// A Collective Event Type hosted by this user
|
// A Collective Event Type hosted by this user
|
||||||
{
|
{
|
||||||
|
@ -885,7 +883,7 @@ describe("getSchedule", () => {
|
||||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||||
|
|
||||||
createBookingScenario({
|
await createBookingScenario({
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
// An event having two users with one accepted booking
|
// An event having two users with one accepted booking
|
||||||
{
|
{
|
||||||
|
@ -1010,7 +1008,7 @@ describe("getSchedule", () => {
|
||||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||||
|
|
||||||
createBookingScenario({
|
await createBookingScenario({
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
// An event having two users with one accepted booking
|
// An event having two users with one accepted booking
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
import type { EventType } from "@prisma/client";
|
import type { EventType } from "@prisma/client";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
|
|
@ -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 appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
|
||||||
import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n";
|
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
|
||||||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
import type {
|
|
||||||
EventType as PrismaEventType,
|
|
||||||
User as PrismaUser,
|
|
||||||
Booking as PrismaBooking,
|
|
||||||
App as PrismaApp,
|
|
||||||
} from "@prisma/client";
|
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import type { WebhookTriggerEvents } from "@prisma/client";
|
import type { WebhookTriggerEvents } from "@prisma/client";
|
||||||
|
import type Stripe from "stripe";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { expect } from "vitest";
|
|
||||||
import "vitest-fetch-mock";
|
import "vitest-fetch-mock";
|
||||||
|
|
||||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||||
|
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
import type { NewCalendarEventType } from "@calcom/types/Calendar";
|
||||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||||
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
|
||||||
|
import { getMockPaymentService } from "./MockPaymentService";
|
||||||
|
|
||||||
type App = {
|
type App = {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
@ -78,7 +76,7 @@ type InputUser = typeof TestData.users.example & { id: number } & {
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputEventType = {
|
export type InputEventType = {
|
||||||
id: number;
|
id: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
length?: number;
|
length?: number;
|
||||||
|
@ -94,9 +92,11 @@ type InputEventType = {
|
||||||
beforeEventBuffer?: number;
|
beforeEventBuffer?: number;
|
||||||
afterEventBuffer?: number;
|
afterEventBuffer?: number;
|
||||||
requiresConfirmation?: boolean;
|
requiresConfirmation?: boolean;
|
||||||
};
|
} & Partial<Omit<Prisma.EventTypeCreateInput, "users">>;
|
||||||
|
|
||||||
type InputBooking = {
|
type InputBooking = {
|
||||||
|
id?: number;
|
||||||
|
uid?: string;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
@ -104,14 +104,40 @@ type InputBooking = {
|
||||||
title?: string;
|
title?: string;
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
attendees?: { email: string }[];
|
attendees?: { email: string }[];
|
||||||
|
references?: {
|
||||||
|
type: string;
|
||||||
|
uid: string;
|
||||||
|
meetingId?: string;
|
||||||
|
meetingPassword?: string;
|
||||||
|
meetingUrl?: string;
|
||||||
|
bookingId?: number;
|
||||||
|
externalCalendarId?: string;
|
||||||
|
deleted?: boolean;
|
||||||
|
credentialId?: number;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const Timezones = {
|
const Timezones = {
|
||||||
"+5:30": "Asia/Kolkata",
|
"+5:30": "Asia/Kolkata",
|
||||||
"+6:00": "Asia/Dhaka",
|
"+6:00": "Asia/Dhaka",
|
||||||
};
|
};
|
||||||
|
logger.setSettings({ minLevel: "silly" });
|
||||||
|
|
||||||
function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
|
async function addEventTypesToDb(
|
||||||
|
eventTypes: (Prisma.EventTypeCreateInput & {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
users?: any[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
workflows?: any[];
|
||||||
|
})[]
|
||||||
|
) {
|
||||||
|
logger.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
|
||||||
|
await prismock.eventType.createMany({
|
||||||
|
data: eventTypes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
|
||||||
const baseEventType = {
|
const baseEventType = {
|
||||||
title: "Base EventType Title",
|
title: "Base EventType Title",
|
||||||
slug: "base-event-type-slug",
|
slug: "base-event-type-slug",
|
||||||
|
@ -119,7 +145,7 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
|
||||||
beforeEventBuffer: 0,
|
beforeEventBuffer: 0,
|
||||||
afterEventBuffer: 0,
|
afterEventBuffer: 0,
|
||||||
schedulingType: null,
|
schedulingType: null,
|
||||||
|
length: 15,
|
||||||
//TODO: What is the purpose of periodStartDate and periodEndDate? Test these?
|
//TODO: What is the purpose of periodStartDate and periodEndDate? Test these?
|
||||||
periodStartDate: new Date("2022-01-21T09:03:48.000Z"),
|
periodStartDate: new Date("2022-01-21T09:03:48.000Z"),
|
||||||
periodEndDate: new Date("2022-01-21T09:03:48.000Z"),
|
periodEndDate: new Date("2022-01-21T09:03:48.000Z"),
|
||||||
|
@ -150,170 +176,162 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
|
||||||
users,
|
users,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
logger.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
|
||||||
logger.silly("TestData: Creating EventType", eventTypes);
|
await addEventTypesToDb(eventTypesWithUsers);
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const eventTypeMock = ({ where }) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const eventType = eventTypesWithUsers.find((e) => e.id === where.id) as unknown as PrismaEventType & {
|
|
||||||
users: PrismaUser[];
|
|
||||||
};
|
|
||||||
resolve(eventType);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
prismaMock.eventType.findUnique.mockImplementation(eventTypeMock);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockImplementation(eventTypeMock);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[]) {
|
function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
|
||||||
logger.silly("TestData: Creating Bookings", bookings);
|
prismock.bookingReference.createMany({
|
||||||
|
data: bookingReferences,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
prismaMock.booking.findMany.mockImplementation((findManyArg) => {
|
|
||||||
// @ts-expect-error Prisma v5 breaks this
|
|
||||||
const where = findManyArg?.where || {};
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
resolve(
|
|
||||||
// @ts-expect-error Prisma v5 breaks this
|
|
||||||
bookings
|
|
||||||
// We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed
|
|
||||||
.filter((booking) => {
|
|
||||||
/**
|
|
||||||
* A user is considered busy within a given time period if there
|
|
||||||
* is a booking they own OR host. This function mocks some of the logic
|
|
||||||
* for each condition. For details see the following ticket:
|
|
||||||
* https://github.com/calcom/cal.com/issues/6374
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ~~ FIRST CONDITION ensures that this booking is owned by this user
|
|
||||||
// and that the status is what we want
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const statusIn = where.OR[0].status?.in || [];
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const userIdIn = where.OR[0].userId?.in || [];
|
|
||||||
const firstConditionMatches =
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
statusIn.includes(booking.status) &&
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
(booking.userId === where.OR[0].userId || userIdIn.includes(booking.userId));
|
|
||||||
|
|
||||||
// We return this booking if either condition is met
|
|
||||||
return firstConditionMatches;
|
|
||||||
})
|
|
||||||
.map((booking) => ({
|
|
||||||
uid: uuidv4(),
|
|
||||||
title: "Test Booking Title",
|
|
||||||
...booking,
|
|
||||||
eventType: eventTypes.find((eventType) => eventType.id === booking.eventTypeId),
|
|
||||||
})) as unknown as PrismaBooking[]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addWebhooks(webhooks: InputWebhook[]) {
|
async function addBookingsToDb(
|
||||||
prismaMock.webhook.findMany.mockResolvedValue(
|
bookings: (Prisma.BookingCreateInput & {
|
||||||
// @ts-expect-error Prisma v5 breaks this
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
webhooks.map((webhook) => {
|
references: any[];
|
||||||
return {
|
})[]
|
||||||
...webhook,
|
) {
|
||||||
payloadTemplate: null,
|
await prismock.booking.createMany({
|
||||||
secret: null,
|
data: bookings,
|
||||||
id: uuidv4(),
|
});
|
||||||
createdAt: new Date(),
|
logger.silly(
|
||||||
userId: webhook.userId || null,
|
"TestData: Booking as in DB",
|
||||||
eventTypeId: webhook.eventTypeId || null,
|
JSON.stringify({
|
||||||
teamId: webhook.teamId || null,
|
bookings: await prismock.booking.findMany({
|
||||||
};
|
include: {
|
||||||
|
references: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addUsers(users: InputUser[]) {
|
async function addBookings(bookings: InputBooking[]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
logger.silly("TestData: Creating Bookings", JSON.stringify(bookings));
|
||||||
// @ts-ignore
|
const allBookings = [...bookings].map((booking) => {
|
||||||
prismaMock.user.findUniqueOrThrow.mockImplementation((findUniqueArgs) => {
|
if (booking.references) {
|
||||||
return new Promise((resolve) => {
|
addBookingReferencesToDB(
|
||||||
// @ts-expect-error Prisma v5 breaks this
|
booking.references.map((reference) => {
|
||||||
resolve({
|
return {
|
||||||
// @ts-expect-error Prisma v5 breaks this
|
...reference,
|
||||||
email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`,
|
bookingId: booking.id,
|
||||||
} as unknown as PrismaUser);
|
};
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uid: uuidv4(),
|
||||||
|
workflowReminders: [],
|
||||||
|
references: [],
|
||||||
|
title: "Test Booking Title",
|
||||||
|
...booking,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.user.findMany.mockResolvedValue(
|
await addBookingsToDb(
|
||||||
// @ts-expect-error Prisma v5 breaks this
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
users.map((user) => {
|
//@ts-ignore
|
||||||
return {
|
allBookings.map((booking) => {
|
||||||
...user,
|
const bookingCreate = booking;
|
||||||
username: `IntegrationTestUser${user.id}`,
|
if (booking.references) {
|
||||||
email: `IntegrationTestUser${user.id}@example.com`,
|
bookingCreate.references = {
|
||||||
};
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
}) as unknown as PrismaUser[]
|
//@ts-ignore
|
||||||
|
createMany: {
|
||||||
|
data: booking.references,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return bookingCreate;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function addWebhooksToDb(webhooks: any[]) {
|
||||||
|
await prismock.webhook.createMany({
|
||||||
|
data: webhooks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addWebhooks(webhooks: InputWebhook[]) {
|
||||||
|
logger.silly("TestData: Creating Webhooks", webhooks);
|
||||||
|
|
||||||
|
await addWebhooksToDb(webhooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma.ScheduleCreateInput[] })[]) {
|
||||||
|
logger.silly("TestData: Creating Users", JSON.stringify(users));
|
||||||
|
await prismock.user.createMany({
|
||||||
|
data: users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUsers(users: InputUser[]) {
|
||||||
|
const prismaUsersCreate = users.map((user) => {
|
||||||
|
const newUser = user;
|
||||||
|
if (user.schedules) {
|
||||||
|
newUser.schedules = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
createMany: {
|
||||||
|
data: user.schedules.map((schedule) => {
|
||||||
|
return {
|
||||||
|
...schedule,
|
||||||
|
availability: {
|
||||||
|
createMany: {
|
||||||
|
data: schedule.availability,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (user.credentials) {
|
||||||
|
newUser.credentials = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
createMany: {
|
||||||
|
data: user.credentials,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newUser;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
await addUsersToDb(prismaUsersCreate);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBookingScenario(data: ScenarioData) {
|
export async function createBookingScenario(data: ScenarioData) {
|
||||||
logger.silly("TestData: Creating Scenario", data);
|
logger.silly("TestData: Creating Scenario", JSON.stringify({ data }));
|
||||||
addUsers(data.users);
|
await addUsers(data.users);
|
||||||
|
|
||||||
const eventType = addEventTypes(data.eventTypes, data.users);
|
const eventType = await addEventTypes(data.eventTypes, data.users);
|
||||||
if (data.apps) {
|
if (data.apps) {
|
||||||
// @ts-expect-error Prisma v5 breaks this
|
prismock.app.createMany({
|
||||||
prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]);
|
data: data.apps,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
});
|
||||||
//@ts-ignore
|
|
||||||
const appMock = ({ where: { slug: whereSlug } }) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!data.apps) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundApp = data.apps.find(({ slug }) => slug == whereSlug);
|
|
||||||
//TODO: Pass just the app name in data.apps and maintain apps in a separate object or load them dyamically
|
|
||||||
resolve(
|
|
||||||
({
|
|
||||||
...foundApp,
|
|
||||||
...(foundApp?.slug ? TestData.apps[foundApp.slug as keyof typeof TestData.apps] || {} : {}),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
categories: [],
|
|
||||||
} as PrismaApp) || null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// FIXME: How do we know which app to return?
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
prismaMock.app.findUnique.mockImplementation(appMock);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
prismaMock.app.findFirst.mockImplementation(appMock);
|
|
||||||
}
|
}
|
||||||
data.bookings = data.bookings || [];
|
data.bookings = data.bookings || [];
|
||||||
allowSuccessfulBookingCreation();
|
// allowSuccessfulBookingCreation();
|
||||||
addBookings(data.bookings, data.eventTypes);
|
await addBookings(data.bookings);
|
||||||
// mockBusyCalendarTimes([]);
|
// mockBusyCalendarTimes([]);
|
||||||
addWebhooks(data.webhooks || []);
|
await addWebhooks(data.webhooks || []);
|
||||||
|
// addPaymentMock();
|
||||||
return {
|
return {
|
||||||
eventType,
|
eventType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// async function addPaymentsToDb(payments: Prisma.PaymentCreateInput[]) {
|
||||||
|
// await prismaMock.payment.createMany({
|
||||||
|
// data: payments,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This fn indents to /ally compute day, month, year for the purpose of testing.
|
* This fn indents to /ally compute day, month, year for the purpose of testing.
|
||||||
* We are not using DayJS because that's actually being tested by this code.
|
* We are not using DayJS because that's actually being tested by this code.
|
||||||
|
@ -372,9 +390,11 @@ export function getMockedCredential({
|
||||||
scope: string;
|
scope: string;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
|
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
|
||||||
return {
|
return {
|
||||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
type: app.type,
|
||||||
appId: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].slug,
|
appId: app.slug,
|
||||||
|
app: app,
|
||||||
key: {
|
key: {
|
||||||
expiry_date: Date.now() + 1000000,
|
expiry_date: Date.now() + 1000000,
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
|
@ -399,7 +419,16 @@ export function getZoomAppCredential() {
|
||||||
return getMockedCredential({
|
return getMockedCredential({
|
||||||
metadataLookupKey: "zoomvideo",
|
metadataLookupKey: "zoomvideo",
|
||||||
key: {
|
key: {
|
||||||
scope: "meeting:writed",
|
scope: "meeting:write",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStripeAppCredential() {
|
||||||
|
return getMockedCredential({
|
||||||
|
metadataLookupKey: "stripepayment",
|
||||||
|
key: {
|
||||||
|
scope: "read_write",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -466,6 +495,7 @@ export const TestData = {
|
||||||
apps: {
|
apps: {
|
||||||
"google-calendar": {
|
"google-calendar": {
|
||||||
slug: "google-calendar",
|
slug: "google-calendar",
|
||||||
|
enabled: true,
|
||||||
dirName: "whatever",
|
dirName: "whatever",
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
|
@ -479,6 +509,38 @@ export const TestData = {
|
||||||
"daily-video": {
|
"daily-video": {
|
||||||
slug: "daily-video",
|
slug: "daily-video",
|
||||||
dirName: "whatever",
|
dirName: "whatever",
|
||||||
|
enabled: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
keys: {
|
||||||
|
expiry_date: Infinity,
|
||||||
|
api_key: "",
|
||||||
|
scale_plan: "false",
|
||||||
|
client_id: "client_id",
|
||||||
|
client_secret: "client_secret",
|
||||||
|
redirect_uris: ["http://localhost:3000/auth/callback"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zoomvideo: {
|
||||||
|
slug: "zoom",
|
||||||
|
enabled: true,
|
||||||
|
dirName: "whatever",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
keys: {
|
||||||
|
expiry_date: Infinity,
|
||||||
|
api_key: "",
|
||||||
|
scale_plan: "false",
|
||||||
|
client_id: "client_id",
|
||||||
|
client_secret: "client_secret",
|
||||||
|
redirect_uris: ["http://localhost:3000/auth/callback"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"stripe-payment": {
|
||||||
|
//TODO: Read from appStoreMeta
|
||||||
|
slug: "stripe",
|
||||||
|
enabled: true,
|
||||||
|
dirName: "stripepayment",
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
keys: {
|
keys: {
|
||||||
|
@ -493,14 +555,6 @@ export const TestData = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function allowSuccessfulBookingCreation() {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-ignore
|
|
||||||
prismaMock.booking.create.mockImplementation(function (booking) {
|
|
||||||
return booking.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MockError extends Error {
|
export class MockError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
@ -540,6 +594,7 @@ export function getScenarioData({
|
||||||
usersApartFromOrganizer = [],
|
usersApartFromOrganizer = [],
|
||||||
apps = [],
|
apps = [],
|
||||||
webhooks,
|
webhooks,
|
||||||
|
bookings,
|
||||||
}: // hosts = [],
|
}: // hosts = [],
|
||||||
{
|
{
|
||||||
organizer: ReturnType<typeof getOrganizer>;
|
organizer: ReturnType<typeof getOrganizer>;
|
||||||
|
@ -547,6 +602,7 @@ export function getScenarioData({
|
||||||
apps: ScenarioData["apps"];
|
apps: ScenarioData["apps"];
|
||||||
usersApartFromOrganizer?: ScenarioData["users"];
|
usersApartFromOrganizer?: ScenarioData["users"];
|
||||||
webhooks?: ScenarioData["webhooks"];
|
webhooks?: ScenarioData["webhooks"];
|
||||||
|
bookings?: ScenarioData["bookings"];
|
||||||
// hosts?: ScenarioData["hosts"];
|
// hosts?: ScenarioData["hosts"];
|
||||||
}) {
|
}) {
|
||||||
const users = [organizer, ...usersApartFromOrganizer];
|
const users = [organizer, ...usersApartFromOrganizer];
|
||||||
|
@ -561,22 +617,28 @@ export function getScenarioData({
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
// hosts: [...hosts],
|
// hosts: [...hosts],
|
||||||
eventTypes: [...eventTypes],
|
eventTypes: eventTypes.map((eventType, index) => {
|
||||||
|
return {
|
||||||
|
...eventType,
|
||||||
|
title: `Test Event Type - ${index + 1}`,
|
||||||
|
description: `It's a test event type - ${index + 1}`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
users,
|
users,
|
||||||
apps: [...apps],
|
apps: [...apps],
|
||||||
webhooks,
|
webhooks,
|
||||||
|
bookings: bookings || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockEnableEmailFeature() {
|
export function enableEmailFeature() {
|
||||||
// @ts-expect-error Prisma v5 breaks this
|
prismock.feature.create({
|
||||||
prismaMock.feature.findMany.mockResolvedValue([
|
data: {
|
||||||
{
|
|
||||||
slug: "emails",
|
slug: "emails",
|
||||||
// It's a kill switch
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
type: "KILL_SWITCH",
|
||||||
},
|
},
|
||||||
]);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockNoTranslations() {
|
export function mockNoTranslations() {
|
||||||
|
@ -589,20 +651,74 @@ export function mockNoTranslations() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof appStoreMetadata) {
|
export function mockCalendarToHaveNoBusySlots(
|
||||||
|
metadataLookupKey: keyof typeof appStoreMetadata,
|
||||||
|
calendarData?: {
|
||||||
|
create: {
|
||||||
|
uid: string;
|
||||||
|
};
|
||||||
|
update?: {
|
||||||
|
uid: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
) {
|
||||||
const appStoreLookupKey = metadataLookupKey;
|
const appStoreLookupKey = metadataLookupKey;
|
||||||
|
const normalizedCalendarData = calendarData || {
|
||||||
|
create: {
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
uid: "UPDATED_MOCK_ID",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
logger.silly(`Mocking ${appStoreLookupKey} on appStoreMock`);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const createEventCalls: any[] = [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const updateEventCalls: any[] = [];
|
||||||
|
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
|
||||||
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
|
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
|
||||||
lib: {
|
lib: {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
CalendarService: function MockCalendarService() {
|
CalendarService: function MockCalendarService() {
|
||||||
return {
|
return {
|
||||||
createEvent: () => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
createEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
|
||||||
|
const [calEvent, credentialId] = rest;
|
||||||
|
logger.silly(
|
||||||
|
"mockCalendarToHaveNoBusySlots.createEvent",
|
||||||
|
JSON.stringify({ calEvent, credentialId })
|
||||||
|
);
|
||||||
|
createEventCalls.push(rest);
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "daily_video",
|
type: app.type,
|
||||||
id: "dailyEventName",
|
additionalInfo: {},
|
||||||
password: "dailyvideopass",
|
uid: "PROBABLY_UNUSED_UID",
|
||||||
url: "http://dailyvideo.example.com",
|
id: normalizedCalendarData.create.uid,
|
||||||
|
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
|
||||||
|
password: "MOCK_PASSWORD",
|
||||||
|
url: "https://UNUSED_URL",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
updateEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
|
||||||
|
const [uid, event, externalCalendarId] = rest;
|
||||||
|
logger.silly(
|
||||||
|
"mockCalendarToHaveNoBusySlots.updateEvent",
|
||||||
|
JSON.stringify({ uid, event, externalCalendarId })
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line prefer-rest-params
|
||||||
|
updateEventCalls.push(rest);
|
||||||
|
return Promise.resolve({
|
||||||
|
type: app.type,
|
||||||
|
additionalInfo: {},
|
||||||
|
uid: "PROBABLY_UNUSED_UID",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
id: normalizedCalendarData.update!.uid!,
|
||||||
|
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
|
||||||
|
password: "MOCK_PASSWORD",
|
||||||
|
url: "https://UNUSED_URL",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getAvailability: (): Promise<EventBusyDate[]> => {
|
getAvailability: (): Promise<EventBusyDate[]> => {
|
||||||
|
@ -614,16 +730,37 @@ export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof ap
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
createEventCalls,
|
||||||
|
updateEventCalls,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockSuccessfulVideoMeetingCreation({
|
export function mockSuccessfulVideoMeetingCreation({
|
||||||
metadataLookupKey,
|
metadataLookupKey,
|
||||||
appStoreLookupKey,
|
appStoreLookupKey,
|
||||||
|
videoMeetingData,
|
||||||
}: {
|
}: {
|
||||||
metadataLookupKey: string;
|
metadataLookupKey: string;
|
||||||
appStoreLookupKey?: string;
|
appStoreLookupKey?: string;
|
||||||
|
videoMeetingData?: {
|
||||||
|
password: string;
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
|
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
|
||||||
|
videoMeetingData = videoMeetingData || {
|
||||||
|
id: "MOCK_ID",
|
||||||
|
password: "MOCK_PASS",
|
||||||
|
url: `http://mock-${metadataLookupKey}.example.com`,
|
||||||
|
};
|
||||||
|
logger.silly(
|
||||||
|
"mockSuccessfulVideoMeetingCreation",
|
||||||
|
JSON.stringify({ metadataLookupKey, appStoreLookupKey })
|
||||||
|
);
|
||||||
|
const createMeetingCalls: any[] = [];
|
||||||
|
const updateMeetingCalls: any[] = [];
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
|
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
|
||||||
|
@ -633,12 +770,31 @@ export function mockSuccessfulVideoMeetingCreation({
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
VideoApiAdapter: () => ({
|
VideoApiAdapter: () => ({
|
||||||
createMeeting: () => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
createMeeting: (...rest: any[]) => {
|
||||||
|
createMeetingCalls.push(rest);
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||||
id: "MOCK_ID",
|
...videoMeetingData,
|
||||||
password: "MOCK_PASS",
|
});
|
||||||
url: `http://mock-${metadataLookupKey}.example.com`,
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
updateMeeting: async (...rest: any[]) => {
|
||||||
|
const [bookingRef, calEvent] = rest;
|
||||||
|
updateMeetingCalls.push(rest);
|
||||||
|
if (!bookingRef.type) {
|
||||||
|
throw new Error("bookingRef.type is not defined");
|
||||||
|
}
|
||||||
|
if (!calEvent.organizer) {
|
||||||
|
throw new Error("calEvent.organizer is not defined");
|
||||||
|
}
|
||||||
|
logger.silly(
|
||||||
|
"mockSuccessfulVideoMeetingCreation.updateMeeting",
|
||||||
|
JSON.stringify({ bookingRef, calEvent })
|
||||||
|
);
|
||||||
|
return Promise.resolve({
|
||||||
|
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||||
|
...videoMeetingData,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -646,6 +802,37 @@ export function mockSuccessfulVideoMeetingCreation({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
createMeetingCalls,
|
||||||
|
updateMeetingCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockPaymentApp({
|
||||||
|
metadataLookupKey,
|
||||||
|
appStoreLookupKey,
|
||||||
|
}: {
|
||||||
|
metadataLookupKey: string;
|
||||||
|
appStoreLookupKey?: string;
|
||||||
|
}) {
|
||||||
|
appStoreLookupKey = appStoreLookupKey || metadataLookupKey;
|
||||||
|
const { paymentUid, externalId, MockPaymentService } = getMockPaymentService();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolve({
|
||||||
|
lib: {
|
||||||
|
PaymentService: MockPaymentService,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentUid,
|
||||||
|
externalId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockErrorOnVideoMeetingCreation({
|
export function mockErrorOnVideoMeetingCreation({
|
||||||
|
@ -675,39 +862,6 @@ export function mockErrorOnVideoMeetingCreation({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expectWebhookToHaveBeenCalledWith(
|
|
||||||
subscriberUrl: string,
|
|
||||||
data: {
|
|
||||||
triggerEvent: WebhookTriggerEvents;
|
|
||||||
payload: { metadata: Record<string, unknown>; responses: Record<string, unknown> };
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const fetchCalls = fetchMock.mock.calls;
|
|
||||||
const webhookFetchCall = fetchCalls.find((call) => call[0] === subscriberUrl);
|
|
||||||
if (!webhookFetchCall) {
|
|
||||||
throw new Error(`Webhook not called with ${subscriberUrl}`);
|
|
||||||
}
|
|
||||||
expect(webhookFetchCall[0]).toBe(subscriberUrl);
|
|
||||||
const body = webhookFetchCall[1]?.body;
|
|
||||||
const parsedBody = JSON.parse((body as string) || "{}");
|
|
||||||
console.log({ payload: parsedBody.payload });
|
|
||||||
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
|
|
||||||
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
|
|
||||||
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
|
|
||||||
: parsedBody.payload.metadata.videoCallUrl;
|
|
||||||
expect(parsedBody.payload.metadata).toContain(data.payload.metadata);
|
|
||||||
expect(parsedBody.payload.responses).toEqual(data.payload.responses);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expectWorkflowToBeTriggered() {
|
|
||||||
// TODO: Implement this.
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expectBookingToBeInDatabase(booking: Partial<Prisma.BookingCreateInput>) {
|
|
||||||
const createBookingCalledWithArgs = prismaMock.booking.create.mock.calls[0];
|
|
||||||
expect(createBookingCalledWithArgs[0].data).toEqual(expect.objectContaining(booking));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBooker({ name, email }: { name: string; email: string }) {
|
export function getBooker({ name, email }: { name: string; email: string }) {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
@ -715,40 +869,28 @@ export function getBooker({ name, email }: { name: string; email: string }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
export function getMockedStripePaymentEvent({ paymentIntentId }: { paymentIntentId: string }) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
return {
|
||||||
namespace jest {
|
id: null,
|
||||||
interface Matchers<R> {
|
data: {
|
||||||
toHaveEmail(expectedEmail: { htmlToContain?: string; to: string }): R;
|
object: {
|
||||||
}
|
id: paymentIntentId,
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Stripe.Event;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect.extend({
|
export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId: string }) {
|
||||||
toHaveEmail(
|
let webhookResponse = null;
|
||||||
testEmail: ReturnType<Fixtures["emails"]["get"]>[number],
|
try {
|
||||||
expectedEmail: {
|
await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }));
|
||||||
//TODO: Support email HTML parsing to target specific elements
|
} catch (e) {
|
||||||
htmlToContain?: string;
|
if (!(e instanceof HttpError)) {
|
||||||
to: string;
|
logger.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
|
||||||
|
} else {
|
||||||
|
logger.error("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
|
||||||
}
|
}
|
||||||
) {
|
webhookResponse = e as HttpError;
|
||||||
let isHtmlContained = true;
|
}
|
||||||
let isToAddressExpected = true;
|
return { webhookResponse };
|
||||||
if (expectedEmail.htmlToContain) {
|
}
|
||||||
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
|
|
||||||
}
|
|
||||||
isToAddressExpected = expectedEmail.to === testEmail.to;
|
|
||||||
|
|
||||||
return {
|
|
||||||
pass: isHtmlContained && isToAddressExpected,
|
|
||||||
message: () => {
|
|
||||||
if (!isHtmlContained) {
|
|
||||||
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't contained in ${testEmail.to}`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -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",
|
"lint-staged": "^12.5.0",
|
||||||
"mailhog": "^4.16.0",
|
"mailhog": "^4.16.0",
|
||||||
"prettier": "^2.8.6",
|
"prettier": "^2.8.6",
|
||||||
|
"prismock": "^1.21.1",
|
||||||
"tsc-absolute": "^1.0.0",
|
"tsc-absolute": "^1.0.0",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"vitest": "^0.34.3",
|
"vitest": "^0.34.3",
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
|
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
|
|
@ -260,6 +260,9 @@ export const createEvent = async (
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (!creationResult) {
|
||||||
|
logger.silly("createEvent failed", { success, uid, creationResult, originalEvent: calEvent, calError });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appName: credential.appId || "",
|
appName: credential.appId || "",
|
||||||
|
|
|
@ -80,6 +80,7 @@ export default class EventManager {
|
||||||
* @param user
|
* @param user
|
||||||
*/
|
*/
|
||||||
constructor(user: EventManagerUser) {
|
constructor(user: EventManagerUser) {
|
||||||
|
logger.silly("Initializing EventManager", JSON.stringify({ user }));
|
||||||
const appCredentials = getApps(user.credentials, true).flatMap((app) =>
|
const appCredentials = getApps(user.credentials, true).flatMap((app) =>
|
||||||
app.credentials.map((creds) => ({ ...creds, appName: app.name }))
|
app.credentials.map((creds) => ({ ...creds, appName: app.name }))
|
||||||
);
|
);
|
||||||
|
@ -312,7 +313,6 @@ export default class EventManager {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
referencesToCreate: [...booking.references],
|
referencesToCreate: [...booking.references],
|
||||||
|
@ -361,6 +361,7 @@ export default class EventManager {
|
||||||
[] as DestinationCalendar[]
|
[] as DestinationCalendar[]
|
||||||
);
|
);
|
||||||
for (const destination of destinationCalendars) {
|
for (const destination of destinationCalendars) {
|
||||||
|
logger.silly("Creating Calendar event", JSON.stringify({ destination }));
|
||||||
if (destination.credentialId) {
|
if (destination.credentialId) {
|
||||||
let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
|
let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
|
@ -400,13 +401,21 @@ export default class EventManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
logger.silly(
|
||||||
|
"No destination Calendar found, falling back to first connected calendar",
|
||||||
|
JSON.stringify({
|
||||||
|
calendarCredentials: this.calendarCredentials,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Not ideal but, if we don't find a destination calendar,
|
* Not ideal but, if we don't find a destination calendar,
|
||||||
* fallback to the first connected calendar
|
* fallback to the first connected calendar - Shouldn't be a CRM calendar
|
||||||
*/
|
*/
|
||||||
const [credential] = this.calendarCredentials.filter((cred) => cred.type === "calendar");
|
const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar"));
|
||||||
if (credential) {
|
if (credential) {
|
||||||
const createdEvent = await createEvent(credential, event);
|
const createdEvent = await createEvent(credential, event);
|
||||||
|
logger.silly("Created Calendar event", { createdEvent });
|
||||||
if (createdEvent) {
|
if (createdEvent) {
|
||||||
createdEvents.push(createdEvent);
|
createdEvents.push(createdEvent);
|
||||||
}
|
}
|
||||||
|
@ -503,6 +512,7 @@ export default class EventManager {
|
||||||
): Promise<Array<EventResult<NewCalendarEventType>>> {
|
): Promise<Array<EventResult<NewCalendarEventType>>> {
|
||||||
let calendarReference: PartialReference[] | undefined = undefined,
|
let calendarReference: PartialReference[] | undefined = undefined,
|
||||||
credential;
|
credential;
|
||||||
|
logger.silly("updateAllCalendarEvents", JSON.stringify({ event, booking, newBookingId }));
|
||||||
try {
|
try {
|
||||||
// If a newBookingId is given, update that calendar event
|
// If a newBookingId is given, update that calendar event
|
||||||
let newBooking;
|
let newBooking;
|
||||||
|
@ -564,6 +574,7 @@ export default class EventManager {
|
||||||
(credential) => credential.type === reference?.type
|
(credential) => credential.type === reference?.type
|
||||||
);
|
);
|
||||||
for (const credential of credentials) {
|
for (const credential of credentials) {
|
||||||
|
logger.silly("updateAllCalendarEvents-credential", JSON.stringify({ credentials }));
|
||||||
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
|
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
|
||||||
|
|
||||||
for (const cred of withCredentials) {
|
for (const cred of withCredentials) {
|
||||||
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
|
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
|
||||||
|
logger.silly("getVideoAdapters", JSON.stringify({ appName, cred }));
|
||||||
const appImportFn = appStore[appName as keyof typeof appStore];
|
const appImportFn = appStore[appName as keyof typeof appStore];
|
||||||
|
|
||||||
// Static Link Video Apps don't exist in packages/app-store/index.ts(it's manually maintained at the moment) and they aren't needed there anyway.
|
// Static Link Video Apps don't exist in packages/app-store/index.ts(it's manually maintained at the moment) and they aren't needed there anyway.
|
||||||
|
@ -38,6 +39,8 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
|
||||||
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
|
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
|
||||||
const videoAdapter = makeVideoApiAdapter(cred);
|
const videoAdapter = makeVideoApiAdapter(cred);
|
||||||
videoAdapters.push(videoAdapter);
|
videoAdapters.push(videoAdapter);
|
||||||
|
} else {
|
||||||
|
log.error(`App ${appName} doesn't have 'lib.VideoApiAdapter' defined`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,7 +54,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) =>
|
||||||
|
|
||||||
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
|
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
|
||||||
const uid: string = getUid(calEvent);
|
const uid: string = getUid(calEvent);
|
||||||
|
log.silly("videoClient:createMeeting", JSON.stringify({ credential, uid, calEvent }));
|
||||||
if (!credential || !credential.appId) {
|
if (!credential || !credential.appId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
|
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
|
||||||
|
@ -116,21 +119,23 @@ const updateMeeting = async (
|
||||||
bookingRef: PartialReference | null
|
bookingRef: PartialReference | null
|
||||||
): Promise<EventResult<VideoCallData>> => {
|
): Promise<EventResult<VideoCallData>> => {
|
||||||
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||||
|
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const [firstVideoAdapter] = await getVideoAdapters([credential]);
|
const [firstVideoAdapter] = await getVideoAdapters([credential]);
|
||||||
const updatedMeeting =
|
const canCallUpdateMeeting = !!(credential && bookingRef);
|
||||||
credential && bookingRef
|
const updatedMeeting = canCallUpdateMeeting
|
||||||
? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => {
|
? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => {
|
||||||
await sendBrokenIntegrationEmail(calEvent, "video");
|
await sendBrokenIntegrationEmail(calEvent, "video");
|
||||||
log.error("updateMeeting failed", e, calEvent);
|
log.error("updateMeeting failed", e, calEvent);
|
||||||
success = false;
|
success = false;
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!updatedMeeting) {
|
if (!updatedMeeting) {
|
||||||
|
log.error(
|
||||||
|
"updateMeeting failed",
|
||||||
|
JSON.stringify({ bookingRef, canCallUpdateMeeting, calEvent, credential })
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
appName: credential.appId || "",
|
appName: credential.appId || "",
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
|
|
|
@ -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,
|
teamId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
|
||||||
|
|
||||||
const handleSeats = async () => {
|
const handleSeats = async () => {
|
||||||
let resultBooking:
|
let resultBooking:
|
||||||
| (Partial<Booking> & {
|
| (Partial<Booking> & {
|
||||||
|
@ -1219,6 +1217,7 @@ async function handler(
|
||||||
seatReferenceUid?: string;
|
seatReferenceUid?: string;
|
||||||
paymentUid?: string;
|
paymentUid?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
paymentId?: number;
|
||||||
})
|
})
|
||||||
| null = null;
|
| null = null;
|
||||||
|
|
||||||
|
@ -1762,6 +1761,7 @@ async function handler(
|
||||||
resultBooking = { ...foundBooking };
|
resultBooking = { ...foundBooking };
|
||||||
resultBooking["message"] = "Payment required";
|
resultBooking["message"] = "Payment required";
|
||||||
resultBooking["paymentUid"] = payment?.uid;
|
resultBooking["paymentUid"] = payment?.uid;
|
||||||
|
resultBooking["id"] = payment?.id;
|
||||||
} else {
|
} else {
|
||||||
resultBooking = { ...foundBooking };
|
resultBooking = { ...foundBooking };
|
||||||
}
|
}
|
||||||
|
@ -2082,7 +2082,9 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
let videoCallUrl;
|
let videoCallUrl;
|
||||||
|
|
||||||
if (originalRescheduledBooking?.uid) {
|
if (originalRescheduledBooking?.uid) {
|
||||||
|
log.silly("Rescheduling booking", originalRescheduledBooking.uid);
|
||||||
try {
|
try {
|
||||||
// cancel workflow reminders from previous rescheduled booking
|
// cancel workflow reminders from previous rescheduled booking
|
||||||
await cancelWorkflowReminders(originalRescheduledBooking.workflowReminders);
|
await cancelWorkflowReminders(originalRescheduledBooking.workflowReminders);
|
||||||
|
@ -2288,6 +2290,27 @@ async function handler(
|
||||||
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
||||||
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
|
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
|
||||||
}
|
}
|
||||||
|
const metadata = videoCallUrl
|
||||||
|
? {
|
||||||
|
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const webhookData = {
|
||||||
|
...evt,
|
||||||
|
...eventTypeInfo,
|
||||||
|
bookingId: booking?.id,
|
||||||
|
rescheduleUid,
|
||||||
|
rescheduleStartTime: originalRescheduledBooking?.startTime
|
||||||
|
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
||||||
|
: undefined,
|
||||||
|
rescheduleEndTime: originalRescheduledBooking?.endTime
|
||||||
|
? dayjs(originalRescheduledBooking?.endTime).utc().format()
|
||||||
|
: undefined,
|
||||||
|
metadata: { ...metadata, ...reqBody.metadata },
|
||||||
|
eventTypeId,
|
||||||
|
status: "ACCEPTED",
|
||||||
|
smsReminderNumber: booking?.smsReminderNumber || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
if (bookingRequiresPayment) {
|
if (bookingRequiresPayment) {
|
||||||
// Load credentials.app.categories
|
// Load credentials.app.categories
|
||||||
|
@ -2329,9 +2352,23 @@ async function handler(
|
||||||
fullName,
|
fullName,
|
||||||
bookerEmail
|
bookerEmail
|
||||||
);
|
);
|
||||||
|
const subscriberOptionsPaymentInitiated: GetSubscriberOptions = {
|
||||||
|
userId: triggerForUser ? organizerUser.id : null,
|
||||||
|
eventTypeId,
|
||||||
|
triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
||||||
|
teamId,
|
||||||
|
};
|
||||||
|
await handleWebhookTrigger({
|
||||||
|
subscriberOptions: subscriberOptionsPaymentInitiated,
|
||||||
|
eventTrigger: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
||||||
|
webhookData: {
|
||||||
|
...webhookData,
|
||||||
|
paymentId: payment?.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
req.statusCode = 201;
|
req.statusCode = 201;
|
||||||
return { ...booking, message: "Payment required", paymentUid: payment?.uid };
|
return { ...booking, message: "Payment required", paymentUid: payment?.uid, paymentId: payment?.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`);
|
loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`);
|
||||||
|
@ -2340,28 +2377,6 @@ async function handler(
|
||||||
videoCallUrl = booking.location;
|
videoCallUrl = booking.location;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = videoCallUrl
|
|
||||||
? {
|
|
||||||
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const webhookData = {
|
|
||||||
...evt,
|
|
||||||
...eventTypeInfo,
|
|
||||||
bookingId: booking?.id,
|
|
||||||
rescheduleUid,
|
|
||||||
rescheduleStartTime: originalRescheduledBooking?.startTime
|
|
||||||
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
|
||||||
: undefined,
|
|
||||||
rescheduleEndTime: originalRescheduledBooking?.endTime
|
|
||||||
? dayjs(originalRescheduledBooking?.endTime).utc().format()
|
|
||||||
: undefined,
|
|
||||||
metadata: { ...metadata, ...reqBody.metadata },
|
|
||||||
eventTypeId,
|
|
||||||
status: "ACCEPTED",
|
|
||||||
smsReminderNumber: booking?.smsReminderNumber || undefined,
|
|
||||||
};
|
|
||||||
if (isConfirmedByDefault) {
|
if (isConfirmedByDefault) {
|
||||||
try {
|
try {
|
||||||
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
||||||
|
|
|
@ -5,21 +5,19 @@ import type Stripe from "stripe";
|
||||||
|
|
||||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||||
import EventManager from "@calcom/core/EventManager";
|
import EventManager from "@calcom/core/EventManager";
|
||||||
import dayjs from "@calcom/dayjs";
|
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails";
|
||||||
import { sendOrganizerRequestEmail, sendAttendeeRequestEmail } from "@calcom/emails";
|
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
|
||||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { getBooking } from "@calcom/lib/payment/getBooking";
|
||||||
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { prisma } from "@calcom/prisma";
|
||||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
|
||||||
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
|
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
|
||||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
const log = logger.getChildLogger({ prefix: ["[paymentWebhook]"] });
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
|
@ -27,109 +25,7 @@ export const config = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getEventType(id: number) {
|
export async function handleStripePaymentSuccess(event: Stripe.Event) {
|
||||||
return prisma.eventType.findUnique({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
recurringEvent: true,
|
|
||||||
requiresConfirmation: true,
|
|
||||||
metadata: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBooking(bookingId: number) {
|
|
||||||
const booking = await prisma.booking.findUnique({
|
|
||||||
where: {
|
|
||||||
id: bookingId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
...bookingMinimalSelect,
|
|
||||||
eventType: true,
|
|
||||||
smsReminderNumber: true,
|
|
||||||
location: true,
|
|
||||||
eventTypeId: true,
|
|
||||||
userId: true,
|
|
||||||
uid: true,
|
|
||||||
paid: true,
|
|
||||||
destinationCalendar: true,
|
|
||||||
status: true,
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
timeZone: true,
|
|
||||||
timeFormat: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
locale: true,
|
|
||||||
destinationCalendar: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
|
|
||||||
|
|
||||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
|
||||||
let eventTypeRaw: EventTypeRaw | null = null;
|
|
||||||
if (booking.eventTypeId) {
|
|
||||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventType = { ...eventTypeRaw, metadata: EventTypeMetaDataSchema.parse(eventTypeRaw?.metadata) };
|
|
||||||
|
|
||||||
const { user } = booking;
|
|
||||||
|
|
||||||
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
|
||||||
|
|
||||||
const t = await getTranslation(user.locale ?? "en", "common");
|
|
||||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
|
||||||
return {
|
|
||||||
name: attendee.name,
|
|
||||||
email: attendee.email,
|
|
||||||
timeZone: attendee.timeZone,
|
|
||||||
language: {
|
|
||||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
|
||||||
locale: attendee.locale ?? "en",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const attendeesList = await Promise.all(attendeesListPromises);
|
|
||||||
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
|
|
||||||
const evt: CalendarEvent = {
|
|
||||||
type: booking.title,
|
|
||||||
title: booking.title,
|
|
||||||
description: booking.description || undefined,
|
|
||||||
startTime: booking.startTime.toISOString(),
|
|
||||||
endTime: booking.endTime.toISOString(),
|
|
||||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
|
||||||
organizer: {
|
|
||||||
email: user.email,
|
|
||||||
name: user.name!,
|
|
||||||
timeZone: user.timeZone,
|
|
||||||
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
|
|
||||||
language: { translate: t, locale: user.locale ?? "en" },
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
attendees: attendeesList,
|
|
||||||
uid: booking.uid,
|
|
||||||
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
|
|
||||||
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
booking,
|
|
||||||
user,
|
|
||||||
evt,
|
|
||||||
eventType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStripePaymentSuccess(event: Stripe.Event) {
|
|
||||||
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||||
const payment = await prisma.payment.findFirst({
|
const payment = await prisma.payment.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
@ -140,8 +36,10 @@ async function handleStripePaymentSuccess(event: Stripe.Event) {
|
||||||
bookingId: true,
|
bookingId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payment?.bookingId) {
|
if (!payment?.bookingId) {
|
||||||
console.log(JSON.stringify(paymentIntent), JSON.stringify(payment));
|
log.error(JSON.stringify(paymentIntent), JSON.stringify(payment));
|
||||||
|
throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||||
}
|
}
|
||||||
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||||
|
|
||||||
|
@ -164,34 +62,16 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
|
||||||
paid: true,
|
paid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const userWithCredentials = await prisma.user.findUnique({
|
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||||
where: {
|
|
||||||
id: user.id,
|
const requiresConfirmation = doesBookingRequireConfirmation({
|
||||||
},
|
booking: {
|
||||||
select: {
|
...booking,
|
||||||
id: true,
|
eventType,
|
||||||
username: true,
|
|
||||||
timeZone: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
locale: true,
|
|
||||||
destinationCalendar: true,
|
|
||||||
credentials: { select: credentialForCalendarServiceSelect },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
|
||||||
|
|
||||||
let requiresConfirmation = eventType?.requiresConfirmation;
|
|
||||||
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
|
||||||
if (rcThreshold) {
|
|
||||||
if (dayjs(dayjs(booking.startTime).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
|
|
||||||
requiresConfirmation = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!requiresConfirmation) {
|
if (!requiresConfirmation) {
|
||||||
const eventManager = new EventManager(userWithCredentials);
|
const eventManager = new EventManager(user);
|
||||||
const scheduleResult = await eventManager.create(evt);
|
const scheduleResult = await eventManager.create(evt);
|
||||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||||
bookingData.status = BookingStatus.ACCEPTED;
|
bookingData.status = BookingStatus.ACCEPTED;
|
||||||
|
@ -218,7 +98,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
|
||||||
|
|
||||||
if (!requiresConfirmation) {
|
if (!requiresConfirmation) {
|
||||||
await handleConfirmation({
|
await handleConfirmation({
|
||||||
user: userWithCredentials,
|
user,
|
||||||
evt,
|
evt,
|
||||||
prisma,
|
prisma,
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
|
|
|
@ -35,6 +35,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
|
||||||
{ value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" },
|
{ value: WebhookTriggerEvents.BOOKING_CREATED, label: "booking_created" },
|
||||||
{ value: WebhookTriggerEvents.BOOKING_REJECTED, label: "booking_rejected" },
|
{ value: WebhookTriggerEvents.BOOKING_REJECTED, label: "booking_rejected" },
|
||||||
{ value: WebhookTriggerEvents.BOOKING_REQUESTED, label: "booking_requested" },
|
{ value: WebhookTriggerEvents.BOOKING_REQUESTED, label: "booking_requested" },
|
||||||
|
{ value: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, label: "booking_payment_initiated" },
|
||||||
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
|
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
|
||||||
{ value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" },
|
{ value: WebhookTriggerEvents.BOOKING_PAID, label: "booking_paid" },
|
||||||
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
|
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
|
||||||
|
|
|
@ -8,6 +8,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
|
||||||
WebhookTriggerEvents.BOOKING_CREATED,
|
WebhookTriggerEvents.BOOKING_CREATED,
|
||||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||||
WebhookTriggerEvents.BOOKING_PAID,
|
WebhookTriggerEvents.BOOKING_PAID,
|
||||||
|
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
||||||
WebhookTriggerEvents.MEETING_ENDED,
|
WebhookTriggerEvents.MEETING_ENDED,
|
||||||
WebhookTriggerEvents.BOOKING_REQUESTED,
|
WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||||
WebhookTriggerEvents.BOOKING_REJECTED,
|
WebhookTriggerEvents.BOOKING_REJECTED,
|
||||||
|
|
|
@ -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 EventManager from "@calcom/core/EventManager";
|
||||||
import { sendScheduledEmails } from "@calcom/emails";
|
import { sendScheduledEmails } from "@calcom/emails";
|
||||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
|
||||||
|
import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested";
|
||||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
|
||||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getBooking } from "@calcom/lib/payment/getBooking";
|
||||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
||||||
|
|
||||||
import { getTimeFormatStringFromUserTimeFormat } from "../timeFormat";
|
import logger from "../logger";
|
||||||
|
|
||||||
|
const log = logger.getChildLogger({ prefix: ["[handlePaymentSuccess]"] });
|
||||||
export async function handlePaymentSuccess(paymentId: number, bookingId: number) {
|
export async function handlePaymentSuccess(paymentId: number, bookingId: number) {
|
||||||
const booking = await prisma.booking.findUnique({
|
const { booking, user: userWithCredentials, evt, eventType } = await getBooking(bookingId);
|
||||||
where: {
|
|
||||||
id: bookingId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
...bookingMinimalSelect,
|
|
||||||
eventType: true,
|
|
||||||
smsReminderNumber: true,
|
|
||||||
location: true,
|
|
||||||
eventTypeId: true,
|
|
||||||
userId: true,
|
|
||||||
uid: true,
|
|
||||||
paid: true,
|
|
||||||
destinationCalendar: true,
|
|
||||||
status: true,
|
|
||||||
responses: true,
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
credentials: { select: credentialForCalendarServiceSelect },
|
|
||||||
timeZone: true,
|
|
||||||
timeFormat: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
locale: true,
|
|
||||||
destinationCalendar: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
payment: {
|
|
||||||
select: {
|
|
||||||
amount: true,
|
|
||||||
currency: true,
|
|
||||||
paymentOption: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
|
|
||||||
|
|
||||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
|
||||||
let eventTypeRaw: EventTypeRaw | null = null;
|
|
||||||
if (booking.eventTypeId) {
|
|
||||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user: userWithCredentials } = booking;
|
|
||||||
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
|
||||||
const { credentials, ...user } = userWithCredentials;
|
|
||||||
|
|
||||||
const t = await getTranslation(user.locale ?? "en", "common");
|
|
||||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
|
||||||
return {
|
|
||||||
name: attendee.name,
|
|
||||||
email: attendee.email,
|
|
||||||
timeZone: attendee.timeZone,
|
|
||||||
language: {
|
|
||||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
|
||||||
locale: attendee.locale ?? "en",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const attendeesList = await Promise.all(attendeesListPromises);
|
|
||||||
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
|
|
||||||
const evt: CalendarEvent = {
|
|
||||||
type: booking.title,
|
|
||||||
title: booking.title,
|
|
||||||
description: booking.description || undefined,
|
|
||||||
startTime: booking.startTime.toISOString(),
|
|
||||||
endTime: booking.endTime.toISOString(),
|
|
||||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
|
||||||
...getCalEventResponses({
|
|
||||||
booking: booking,
|
|
||||||
bookingFields: booking.eventType?.bookingFields || null,
|
|
||||||
}),
|
|
||||||
organizer: {
|
|
||||||
email: user.email,
|
|
||||||
name: user.name!,
|
|
||||||
timeZone: user.timeZone,
|
|
||||||
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
|
|
||||||
language: { translate: t, locale: user.locale ?? "en" },
|
|
||||||
},
|
|
||||||
attendees: attendeesList,
|
|
||||||
location: booking.location,
|
|
||||||
uid: booking.uid,
|
|
||||||
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
|
|
||||||
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
|
|
||||||
paymentInfo: booking.payment?.[0] && {
|
|
||||||
amount: booking.payment[0].amount,
|
|
||||||
currency: booking.payment[0].currency,
|
|
||||||
paymentOption: booking.payment[0].paymentOption,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (booking.location) evt.location = booking.location;
|
if (booking.location) evt.location = booking.location;
|
||||||
|
|
||||||
|
@ -125,10 +30,16 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
|
||||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventTypeRaw?.requiresConfirmation) {
|
const requiresConfirmation = doesBookingRequireConfirmation({
|
||||||
|
booking: {
|
||||||
|
...booking,
|
||||||
|
eventType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requiresConfirmation) {
|
||||||
delete bookingData.status;
|
delete bookingData.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentUpdate = prisma.payment.update({
|
const paymentUpdate = prisma.payment.update({
|
||||||
where: {
|
where: {
|
||||||
id: paymentId,
|
id: paymentId,
|
||||||
|
@ -146,16 +57,23 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
||||||
|
if (!isConfirmed) {
|
||||||
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
|
if (!requiresConfirmation) {
|
||||||
await handleConfirmation({
|
await handleConfirmation({
|
||||||
user: userWithCredentials,
|
user: userWithCredentials,
|
||||||
evt,
|
evt,
|
||||||
prisma,
|
prisma,
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
booking,
|
booking,
|
||||||
paid: true,
|
paid: true,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await handleBookingRequested({
|
||||||
|
evt,
|
||||||
|
booking,
|
||||||
|
});
|
||||||
|
log.debug(`handling booking request for eventId ${eventType.id}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await sendScheduledEmails({ ...evt });
|
await sendScheduledEmails({ ...evt });
|
||||||
}
|
}
|
||||||
|
@ -165,15 +83,3 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number)
|
||||||
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEventType(id: number) {
|
|
||||||
return prisma.eventType.findUnique({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
recurringEvent: true,
|
|
||||||
requiresConfirmation: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_PAYMENT_INITIATED';
|
|
@ -541,6 +541,7 @@ enum PaymentOption {
|
||||||
|
|
||||||
enum WebhookTriggerEvents {
|
enum WebhookTriggerEvents {
|
||||||
BOOKING_CREATED
|
BOOKING_CREATED
|
||||||
|
BOOKING_PAYMENT_INITIATED
|
||||||
BOOKING_PAID
|
BOOKING_PAID
|
||||||
BOOKING_RESCHEDULED
|
BOOKING_RESCHEDULED
|
||||||
BOOKING_REQUESTED
|
BOOKING_REQUESTED
|
||||||
|
|
|
@ -1,18 +1,90 @@
|
||||||
|
import { PrismockClient } from "prismock";
|
||||||
import { beforeEach, vi } from "vitest";
|
import { beforeEach, vi } from "vitest";
|
||||||
import { mockDeep, mockReset } from "vitest-mock-extended";
|
|
||||||
|
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
import logger from "@calcom/lib/logger";
|
||||||
|
import * as selects from "@calcom/prisma/selects";
|
||||||
|
|
||||||
vi.mock("@calcom/prisma", () => ({
|
vi.mock("@calcom/prisma", () => ({
|
||||||
default: prisma,
|
default: prisma,
|
||||||
prisma,
|
prisma,
|
||||||
availabilityUserSelect: vi.fn(),
|
...selects,
|
||||||
userSelect: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const handlePrismockBugs = () => {
|
||||||
|
const __updateBooking = prismock.booking.update;
|
||||||
|
const __findManyWebhook = prismock.webhook.findMany;
|
||||||
|
const __findManyBooking = prismock.booking.findMany;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
prismock.booking.update = (...rest: any[]) => {
|
||||||
|
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
|
||||||
|
// In booking flows, we encounter such scenario, so let's fix that here directly till it's fixed in prismock
|
||||||
|
if (rest[0].data.references?.createMany) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
rest[0].data.references.createMany = rest[0].data.references?.createMany.data;
|
||||||
|
logger.silly("Fixed Prismock bug");
|
||||||
|
}
|
||||||
|
if (rest[0].data.references?.create) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
rest[0].data.references.create = rest[0].data.references?.create.data;
|
||||||
|
logger.silly("Fixed Prismock bug-1");
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
return __updateBooking(...rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
prismock.webhook.findMany = (...rest: any[]) => {
|
||||||
|
// There is some bug in prismock where it can't handle complex where clauses
|
||||||
|
if (rest[0].where?.OR && rest[0].where.AND) {
|
||||||
|
rest[0].where = undefined;
|
||||||
|
logger.silly("Fixed Prismock bug-2");
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
return __findManyWebhook(...rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
prismock.booking.findMany = (...rest: any[]) => {
|
||||||
|
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
|
||||||
|
// In booking flows, we encounter such scenario, so let's fix that here directly till it's fixed in prismock
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
const where = rest[0]?.where;
|
||||||
|
if (where?.OR) {
|
||||||
|
logger.silly("Fixed Prismock bug-3");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
where.OR.forEach((or: any) => {
|
||||||
|
if (or.startTime?.gte) {
|
||||||
|
or.startTime.gte = or.startTime.gte.toISOString ? or.startTime.gte.toISOString() : or.startTime.gte;
|
||||||
|
}
|
||||||
|
if (or.startTime?.lte) {
|
||||||
|
or.startTime.lte = or.startTime.lte.toISOString ? or.startTime.lte.toISOString() : or.startTime.lte;
|
||||||
|
}
|
||||||
|
if (or.endTime?.gte) {
|
||||||
|
or.endTime.lte = or.endTime.gte.toISOString ? or.endTime.gte.toISOString() : or.endTime.gte;
|
||||||
|
}
|
||||||
|
if (or.endTime?.lte) {
|
||||||
|
or.endTime.lte = or.endTime.lte.toISOString ? or.endTime.lte.toISOString() : or.endTime.lte;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return __findManyBooking(...rest);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockReset(prisma);
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
prismock.reset();
|
||||||
|
handlePrismockBugs();
|
||||||
});
|
});
|
||||||
|
|
||||||
const prisma = mockDeep<PrismaClient>();
|
const prismock = new PrismockClient();
|
||||||
|
|
||||||
|
const prisma = prismock;
|
||||||
export default prisma;
|
export default prisma;
|
||||||
|
|
|
@ -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
Block a user