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_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
|
||||||
|
|
|
@ -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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in New Issue
Block a user