feat: daily webhooks (#12273)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
parent
47277ced2d
commit
518cfbc037
|
@ -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
|
||||
|
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue
Block a user