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_SCALE_PLAN=''
DAILY_WEBHOOK_SECRET=''
# - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google

View File

@ -1,24 +1,38 @@
import type { WebhookTriggerEvents } from "@prisma/client";
import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
const schema = z.object({
recordingId: z.string(),
bookingUID: z.string(),
});
const schema = z
.object({
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({
download_link: z.string(),
@ -39,8 +53,8 @@ const triggerWebhook = async ({
};
}) => {
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 subscriberOptions = {
@ -62,71 +76,62 @@ const triggerWebhook = async ({
await Promise.all(promises);
};
const checkIfUserIsPartOfTheSameTeam = async (
teamId: number | undefined | null,
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;
};
const testRequestSchema = z.object({
test: z.enum(["test"]),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_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({
message: "Invalid Payload",
});
}
const { recordingId, bookingUID } = response.data;
const session = await getServerSession({ req, res });
const { room_name, recording_id, status } = response.data.payload;
if (!session?.user) {
return res.status(401).send({
message: "User not logged in",
if (status !== "finished") {
return res.status(400).send({
message: "Recording not finished",
});
}
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: {
uid: bookingUID,
id: bookingReference.bookingId,
},
select: {
...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({
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 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({
where: {
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 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
if (isSendingEmailsAllowed) {
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
}
return res.status(403).json({ message: "User does not have team plan to send out emails" });
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
} 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" });
}
}

View File

@ -1,10 +1,8 @@
import type { DailyEventObjectRecordingStarted } from "@daily-co/daily-js";
import DailyIframe from "@daily-co/daily-js";
import MarkdownIt from "markdown-it";
import type { GetServerSidePropsContext } from "next";
import Head from "next/head";
import { useState, useEffect, useRef } from "react";
import z from "zod";
import dayjs from "@calcom/dayjs";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
@ -21,19 +19,12 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
const recordingStartedEventResponse = z
.object({
recordingId: z.string(),
})
.passthrough();
export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>;
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export default function JoinCall(props: JoinCallPageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword, booking } = props;
const recordingId = useRef<string | null>(null);
useEffect(() => {
const callFrame = DailyIframe.createFrame({
@ -61,31 +52,12 @@ export default function JoinCall(props: JoinCallPageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }),
});
callFrame.join();
callFrame.on("recording-started", onRecordingStarted).on("recording-stopped", onRecordingStopped);
return () => {
callFrame.destroy();
};
// 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`;
return (
<>

View File

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