Files
calendar/packages/features/instant-meeting/handleInstantMeeting.ts
T
41f63582b2 feat: Add booking and user creation source (#18768)
* migration and init to accept creationSource for new bookings

* V1 create booking

* V1 user creation

* webapp booking + V1 user

* user creation in V1, V2 and webapp

* booking source V2 and fix for v1 user

* fit type

* --fix type

* add test -- WIP

* fix type

* fix type

* ^

* Need more sleep zzz

* -_-

* bump libraries platform

* adds for v2 recurring booking

* fix lint

* instant meetings

* fix: api v2 creation source

* fixup! fix: api v2 creation source

* bump libraries

* add user

* fix test

* fixup! fix test

* add more source

* more source...

* fix type & test --1

* fix type & test --2

* typefix

* fixup test

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2025-01-27 11:01:33 +01:00

345 lines
9.7 KiB
TypeScript

import { Prisma } from "@prisma/client";
import { randomBytes } from "crypto";
import type { NextApiRequest } from "next";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { createInstantMeetingWithCalVideo } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import getBookingDataSchema from "@calcom/features/bookings/lib/getBookingDataSchema";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { getBookingData } from "@calcom/features/bookings/lib/handleNewBooking/getBookingData";
import { getCustomInputsResponses } from "@calcom/features/bookings/lib/handleNewBooking/getCustomInputsResponses";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import { getFullName } from "@calcom/features/form-builder/utils";
import { sendNotification } from "@calcom/features/notifications/sendNotification";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { subscriptionSchema } from "./schema";
const handleInstantMeetingWebhookTrigger = async (args: {
eventTypeId: number;
webhookData: Record<string, unknown>;
teamId: number;
}) => {
const orgId = (await getOrgIdFromMemberOrTeamId({ teamId: args.teamId })) ?? 0;
try {
const eventTrigger = WebhookTriggerEvents.INSTANT_MEETING;
const subscribers = await prisma.webhook.findMany({
where: {
OR: [
{
teamId: {
in: [orgId, args.teamId],
},
},
{
eventTypeId: args.eventTypeId,
},
],
AND: {
eventTriggers: {
has: eventTrigger,
},
active: {
equals: true,
},
},
},
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
},
});
const { webhookData } = args;
const promises = subscribers.map((sub) => {
sendGenericWebhookPayload({
secretKey: sub.secret,
triggerEvent: eventTrigger,
createdAt: new Date().toISOString(),
webhook: sub,
data: webhookData,
}).catch((e) => {
console.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`,
sub,
e
);
});
});
await Promise.all(promises);
} catch (error) {
console.error("Error executing webhook", error);
logger.error("Error while sending webhook", error);
}
};
const triggerBrowserNotifications = async (args: {
title: string;
connectAndJoinUrl: string;
teamId?: number | null;
}) => {
const { title, connectAndJoinUrl, teamId } = args;
if (!teamId) {
logger.warn("No teamId provided, skipping browser notification trigger");
return;
}
const subscribers = await prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
user: {
select: {
id: true,
NotificationsSubscriptions: {
select: {
id: true,
subscription: true,
},
},
},
},
},
});
const promises = subscribers.map((sub) => {
const subscription = sub.user?.NotificationsSubscriptions?.[0]?.subscription;
if (!subscription) return Promise.resolve();
const parsedSubscription = subscriptionSchema.safeParse(JSON.parse(subscription));
if (!parsedSubscription.success) {
logger.error("Invalid subscription", parsedSubscription.error, JSON.stringify(sub.user));
return Promise.resolve();
}
return sendNotification({
subscription: {
endpoint: parsedSubscription.data.endpoint,
keys: {
auth: parsedSubscription.data.keys.auth,
p256dh: parsedSubscription.data.keys.p256dh,
},
},
title: title,
body: "User is waiting for you to join. Click to Connect",
url: connectAndJoinUrl,
actions: [
{
action: "connect-action",
title: "Connect and join",
type: "button",
image: "https://cal.com/api/logo?type=icon",
},
],
});
});
await Promise.allSettled(promises);
};
export type HandleInstantMeetingResponse = {
message: string;
meetingTokenId: number;
bookingId: number;
bookingUid: string;
expires: Date;
userId: number | null;
};
async function handler(req: NextApiRequest) {
let eventType = await getEventTypesFromDB(req.body.eventTypeId);
const isOrgTeamEvent = !!eventType?.team && !!eventType?.team?.parentId;
eventType = {
...eventType,
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
};
if (!eventType.team?.id) {
throw new Error("Only Team Event Types are supported for Instant Meeting");
}
const schema = getBookingDataSchema({
view: req.body?.rescheduleUid ? "reschedule" : "booking",
bookingFields: eventType.bookingFields,
});
const reqBody = await getBookingData({
req,
eventType,
schema,
});
const { email: bookerEmail, name: bookerName } = reqBody;
const translator = short();
const seed = `${reqBody.email}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
const attendeeTimezone = reqBody.timeZone;
const attendeeLanguage = reqBody.language;
const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common");
const fullName = getFullName(bookerName);
const invitee = [
{
email: bookerEmail,
name: fullName,
timeZone: attendeeTimezone,
locale: attendeeLanguage ?? "en",
},
];
const guests = (reqBody.guests || []).reduce((guestArray, guest) => {
guestArray.push({
email: guest,
name: "",
timeZone: attendeeTimezone,
locale: "en",
});
return guestArray;
}, [] as typeof invitee);
const attendeesList = [...invitee, ...guests];
const calVideoMeeting = await createInstantMeetingWithCalVideo(dayjs.utc(reqBody.end).toISOString());
if (!calVideoMeeting) {
throw new Error("Cal Video Meeting Creation Failed");
}
const bookingReferenceToCreate = [
{
type: calVideoMeeting.type,
uid: calVideoMeeting.id,
meetingId: calVideoMeeting.id,
meetingPassword: calVideoMeeting.password,
meetingUrl: calVideoMeeting.url,
},
];
// Create Partial
const newBookingData: Prisma.BookingCreateInput = {
uid,
responses: reqBody.responses === null ? Prisma.JsonNull : reqBody.responses,
title: tAttendees("instant_meeting_with_title", { name: invitee[0].name }),
startTime: dayjs.utc(reqBody.start).toDate(),
endTime: dayjs.utc(reqBody.end).toDate(),
description: reqBody.notes,
customInputs: isPrismaObjOrUndefined(customInputs),
status: BookingStatus.AWAITING_HOST,
references: {
create: bookingReferenceToCreate,
},
location: "integrations:daily",
eventType: {
connect: {
id: reqBody.eventTypeId,
},
},
metadata: { ...reqBody.metadata, videoCallUrl: `${WEBAPP_URL}/video/${uid}` },
attendees: {
createMany: {
data: attendeesList,
},
},
creationSource: req.body.creationSource,
};
const createBookingObj = {
include: {
attendees: true,
},
data: newBookingData,
};
const newBooking = await prisma.booking.create(createBookingObj);
// Create Instant Meeting Token
const token = randomBytes(32).toString("hex");
const eventTypeWithExpiryTimeOffset = await prisma.eventType.findUniqueOrThrow({
where: {
id: req.body.eventTypeId,
},
select: {
instantMeetingExpiryTimeOffsetInSeconds: true,
},
});
const instantMeetingExpiryTimeOffsetInSeconds =
eventTypeWithExpiryTimeOffset?.instantMeetingExpiryTimeOffsetInSeconds ?? 90;
const instantMeetingToken = await prisma.instantMeetingToken.create({
data: {
token,
// current time + offset Seconds
expires: new Date(new Date().getTime() + 1000 * instantMeetingExpiryTimeOffsetInSeconds),
team: {
connect: {
id: eventType.team.id,
},
},
booking: {
connect: {
id: newBooking.id,
},
},
updatedAt: new Date().toISOString(),
},
});
// Trigger Webhook
const webhookData = {
triggerEvent: WebhookTriggerEvents.INSTANT_MEETING,
uid: newBooking.uid,
responses: newBooking.responses,
connectAndJoinUrl: `${WEBAPP_URL}/connect-and-join?token=${token}`,
eventTypeId: eventType.id,
eventTypeTitle: eventType.title,
customInputs: newBooking.customInputs,
};
await handleInstantMeetingWebhookTrigger({
eventTypeId: eventType.id,
webhookData,
teamId: eventType.team?.id,
});
await triggerBrowserNotifications({
title: newBooking.title,
connectAndJoinUrl: webhookData.connectAndJoinUrl,
teamId: eventType.team?.id,
});
return {
message: "Success",
meetingTokenId: instantMeetingToken.id,
bookingId: newBooking.id,
bookingUid: newBooking.uid,
expires: instantMeetingToken.expires,
userId: newBooking.userId,
} satisfies HandleInstantMeetingResponse;
}
export default handler;