feat: daily webhooks (#12273)

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Udit Takkar 2023-12-08 02:38:51 +05:30 committed by GitHub
parent 47277ced2d
commit 518cfbc037
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 68 additions and 115 deletions

View File

@ -37,6 +37,7 @@ BASECAMP3_USER_AGENT=
DAILY_API_KEY= DAILY_API_KEY=
DAILY_SCALE_PLAN='' DAILY_SCALE_PLAN=''
DAILY_WEBHOOK_SECRET=''
# - GOOGLE CALENDAR/MEET/LOGIN # - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google # Needed to enable Google Calendar integration and Login with Google

View File

@ -1,24 +1,38 @@
import type { WebhookTriggerEvents } from "@prisma/client"; import type { WebhookTriggerEvents } from "@prisma/client";
import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod"; import { z } from "zod";
import { DailyLocationType } from "@calcom/app-store/locations"; import { DailyLocationType } from "@calcom/app-store/locations";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient"; import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails"; import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { defaultHandler } from "@calcom/lib/server"; import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CalendarEvent } from "@calcom/types/Calendar";
const schema = z.object({ const schema = z
recordingId: z.string(), .object({
bookingUID: z.string(), version: z.string(),
}); type: z.string(),
id: z.string(),
payload: z.object({
recording_id: z.string(),
end_ts: z.number(),
room_name: z.string(),
start_ts: z.number(),
status: z.string(),
max_participants: z.number(),
duration: z.number(),
s3_key: z.string(),
}),
event_ts: z.number(),
})
.passthrough();
const downloadLinkSchema = z.object({ const downloadLinkSchema = z.object({
download_link: z.string(), download_link: z.string(),
@ -39,8 +53,8 @@ const triggerWebhook = async ({
}; };
}) => { }) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY"; const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId); const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const subscriberOptions = { const subscriberOptions = {
@ -62,71 +76,62 @@ const triggerWebhook = async ({
await Promise.all(promises); await Promise.all(promises);
}; };
const checkIfUserIsPartOfTheSameTeam = async ( const testRequestSchema = z.object({
teamId: number | undefined | null, test: z.enum(["test"]),
userId: number, });
userEmail: string | undefined | null
) => {
if (!teamId) return false;
const getUserQuery = () => {
if (!!userEmail) {
return {
OR: [
{
id: userId,
},
{
email: userEmail,
},
],
};
} else {
return {
id: userId,
};
}
};
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
user: getUserQuery(),
},
},
},
});
return !!team;
};
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) { if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return res.status(405).json({ message: "No SendGrid API key or email" }); return res.status(405).json({ message: "No SendGrid API key or email" });
} }
const response = schema.safeParse(JSON.parse(req.body));
if (!response.success) { if (testRequestSchema.safeParse(req.body).success) {
return res.status(200).json({ message: "Test request successful" });
}
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
if (!hmacSecret) {
return res.status(405).json({ message: "No Daily Webhook Secret" });
}
const signature = `${req.headers["x-webhook-timestamp"]}.${JSON.stringify(req.body)}`;
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
const hmac = createHmac("sha256", base64DecodedSecret);
const computed_signature = hmac.update(signature).digest("base64");
if (req.headers["x-webhook-signature"] !== computed_signature) {
return res.status(403).json({ message: "Signature does not match" });
}
const response = schema.safeParse(req.body);
if (!response.success || response.data.type !== "recording.ready-to-download") {
return res.status(400).send({ return res.status(400).send({
message: "Invalid Payload", message: "Invalid Payload",
}); });
} }
const { recordingId, bookingUID } = response.data; const { room_name, recording_id, status } = response.data.payload;
const session = await getServerSession({ req, res });
if (!session?.user) { if (status !== "finished") {
return res.status(401).send({ return res.status(400).send({
message: "User not logged in", message: "Recording not finished",
}); });
} }
try { try {
const booking = await prisma.booking.findFirst({ const bookingReference = await prisma.bookingReference.findFirst({
where: { type: "daily_video", uid: room_name, meetingId: room_name },
select: { bookingId: true },
});
if (!bookingReference || !bookingReference.bookingId) {
return res.status(404).send({ message: "Booking reference not found" });
}
const booking = await prisma.booking.findUniqueOrThrow({
where: { where: {
uid: bookingUID, id: bookingReference.bookingId,
}, },
select: { select: {
...bookingMinimalSelect, ...bookingMinimalSelect,
@ -153,9 +158,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}, },
}); });
if (!booking || booking.location !== DailyLocationType) { if (!booking || !(booking.location === DailyLocationType || booking?.location?.trim() === "")) {
return res.status(404).send({ return res.status(404).send({
message: `Booking of uid ${bookingUID} does not exist or does not contain daily video as location`, message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
}); });
} }
@ -175,26 +180,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const attendeesList = await Promise.all(attendeesListPromises); const attendeesList = await Promise.all(attendeesListPromises);
const isUserAttendeeOrOrganiser =
booking?.user?.id === session.user.id ||
attendeesList.find(
(attendee) => attendee.id === session.user.id || attendee.email === session.user.email
);
if (!isUserAttendeeOrOrganiser) {
const isUserMemberOfTheTeam = checkIfUserIsPartOfTheSameTeam(
booking?.eventType?.teamId,
session.user.id,
session.user.email
);
if (!isUserMemberOfTheTeam) {
return res.status(403).send({
message: "Unauthorised",
});
}
}
await prisma.booking.update({ await prisma.booking.update({
where: { where: {
uid: booking.uid, uid: booking.uid,
@ -204,7 +189,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}, },
}); });
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId); const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
const downloadLinkResponse = downloadLinkSchema.parse(response); const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link; const downloadLink = downloadLinkResponse.download_link;
@ -242,17 +227,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}, },
}); });
const isSendingEmailsAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam;
// send emails to all attendees only when user has team plan // send emails to all attendees only when user has team plan
if (isSendingEmailsAllowed) { await sendDailyVideoRecordingEmails(evt, downloadLink);
await sendDailyVideoRecordingEmails(evt, downloadLink); return res.status(200).json({ message: "Success" });
return res.status(200).json({ message: "Success" });
}
return res.status(403).json({ message: "User does not have team plan to send out emails" });
} catch (err) { } catch (err) {
console.warn("Error in /recorded-daily-video", err); console.error("Error in /recorded-daily-video", err);
return res.status(500).json({ message: "something went wrong" }); return res.status(500).json({ message: "something went wrong" });
} }
} }

View File

@ -1,10 +1,8 @@
import type { DailyEventObjectRecordingStarted } from "@daily-co/daily-js";
import DailyIframe from "@daily-co/daily-js"; import DailyIframe from "@daily-co/daily-js";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import Head from "next/head"; import Head from "next/head";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import z from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
@ -21,19 +19,12 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr"; import { ssrInit } from "@server/lib/ssr";
const recordingStartedEventResponse = z
.object({
recordingId: z.string(),
})
.passthrough();
export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>; export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>;
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true }); const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export default function JoinCall(props: JoinCallPageProps) { export default function JoinCall(props: JoinCallPageProps) {
const { t } = useLocale(); const { t } = useLocale();
const { meetingUrl, meetingPassword, booking } = props; const { meetingUrl, meetingPassword, booking } = props;
const recordingId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const callFrame = DailyIframe.createFrame({ const callFrame = DailyIframe.createFrame({
@ -61,31 +52,12 @@ export default function JoinCall(props: JoinCallPageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }), ...(typeof meetingPassword === "string" && { token: meetingPassword }),
}); });
callFrame.join(); callFrame.join();
callFrame.on("recording-started", onRecordingStarted).on("recording-stopped", onRecordingStopped);
return () => { return () => {
callFrame.destroy(); callFrame.destroy();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const onRecordingStopped = () => {
const data = { recordingId: recordingId.current, bookingUID: booking.uid };
fetch("/api/recorded-daily-video", {
method: "POST",
body: JSON.stringify(data),
}).catch((err) => {
console.log(err);
});
recordingId.current = null;
};
const onRecordingStarted = (event?: DailyEventObjectRecordingStarted | undefined) => {
const response = recordingStartedEventResponse.parse(event);
recordingId.current = response.recordingId;
};
const title = `${APP_NAME} Video`; const title = `${APP_NAME} Video`;
return ( return (
<> <>

View File

@ -222,6 +222,7 @@
"CRON_ENABLE_APP_SYNC", "CRON_ENABLE_APP_SYNC",
"DAILY_API_KEY", "DAILY_API_KEY",
"DAILY_SCALE_PLAN", "DAILY_SCALE_PLAN",
"DAILY_WEBHOOK_SECRET",
"DEBUG", "DEBUG",
"E2E_TEST_APPLE_CALENDAR_EMAIL", "E2E_TEST_APPLE_CALENDAR_EMAIL",
"E2E_TEST_APPLE_CALENDAR_PASSWORD", "E2E_TEST_APPLE_CALENDAR_PASSWORD",