diff --git a/apps/web/lib/daily-webhook/getBooking.ts b/apps/web/lib/daily-webhook/getBooking.ts new file mode 100644 index 0000000000..e7f7da2144 --- /dev/null +++ b/apps/web/lib/daily-webhook/getBooking.ts @@ -0,0 +1,55 @@ +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma, { bookingMinimalSelect } from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] }); + +// TODO: use BookingRepository +export const getBooking = async (bookingId: number) => { + const booking = await prisma.booking.findUniqueOrThrow({ + where: { + id: bookingId, + }, + select: { + ...bookingMinimalSelect, + uid: true, + location: true, + isRecorded: true, + eventTypeId: true, + eventType: { + select: { + teamId: true, + parentId: true, + }, + }, + user: { + select: { + id: true, + timeZone: true, + email: true, + name: true, + locale: true, + destinationCalendar: true, + }, + }, + }, + }); + + if (!booking) { + log.error( + "Couldn't find Booking Id:", + safeStringify({ + bookingId, + }) + ); + + throw new HttpError({ + message: `Booking of id ${bookingId} does not exist or does not contain daily video as location`, + statusCode: 404, + }); + } + return booking; +}; + +export type getBookingResponse = Awaited>; diff --git a/apps/web/lib/daily-webhook/getBookingReference.ts b/apps/web/lib/daily-webhook/getBookingReference.ts new file mode 100644 index 0000000000..9d34da167f --- /dev/null +++ b/apps/web/lib/daily-webhook/getBookingReference.ts @@ -0,0 +1,24 @@ +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { BookingReferenceRepository } from "@calcom/lib/server/repository/bookingReference"; + +const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] }); + +export const getBookingReference = async (roomName: string) => { + const bookingReference = await BookingReferenceRepository.findDailyVideoReferenceByRoomName({ roomName }); + + if (!bookingReference || !bookingReference.bookingId) { + log.error( + "bookingReference not found error:", + safeStringify({ + bookingReference, + roomName, + }) + ); + + throw new HttpError({ message: "Booking reference not found", statusCode: 200 }); + } + + return bookingReference; +}; diff --git a/apps/web/lib/daily-webhook/getCalendarEvent.ts b/apps/web/lib/daily-webhook/getCalendarEvent.ts new file mode 100644 index 0000000000..e3f76b0e78 --- /dev/null +++ b/apps/web/lib/daily-webhook/getCalendarEvent.ts @@ -0,0 +1,40 @@ +import { getTranslation } from "@calcom/lib/server/i18n"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +import type { getBookingResponse } from "./getBooking"; + +export const getCalendarEvent = async (booking: getBookingResponse) => { + const t = await getTranslation(booking?.user?.locale ?? "en", "common"); + + const attendeesListPromises = booking.attendees.map(async (attendee) => { + return { + id: attendee.id, + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + const evt: CalendarEvent = { + type: booking.title, + title: booking.title, + description: booking.description || undefined, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + email: booking?.userPrimaryEmail || booking.user?.email || "Email-less", + name: booking.user?.name || "Nameless", + timeZone: booking.user?.timeZone || "Europe/London", + language: { translate: t, locale: booking?.user?.locale ?? "en" }, + }, + attendees: attendeesList, + uid: booking.uid, + }; + + return Promise.resolve(evt); +}; diff --git a/apps/web/lib/daily-webhook/schema.ts b/apps/web/lib/daily-webhook/schema.ts new file mode 100644 index 0000000000..ef4dae482e --- /dev/null +++ b/apps/web/lib/daily-webhook/schema.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +const commonSchema = z + .object({ + version: z.string(), + type: z.string(), + id: z.string(), + event_ts: z.number().optional(), + }) + .passthrough(); + +export const meetingEndedSchema = commonSchema.extend({ + payload: z + .object({ + meeting_id: z.string(), + end_ts: z.number().optional(), + room: z.string(), + start_ts: z.number().optional(), + }) + .passthrough(), +}); + +export const recordingReadySchema = commonSchema.extend({ + payload: z.object({ + recording_id: z.string(), + end_ts: z.number().optional(), + room_name: z.string(), + start_ts: z.number().optional(), + status: z.string(), + + max_participants: z.number().optional(), + duration: z.number().optional(), + s3_key: z.string().optional(), + }), +}); + +export const batchProcessorJobFinishedSchema = commonSchema.extend({ + payload: z + .object({ + id: z.string(), + status: z.string(), + input: z.object({ + sourceType: z.string(), + recordingId: z.string(), + }), + output: z + .object({ + transcription: z.array(z.object({ format: z.string() }).passthrough()), + }) + .passthrough(), + }) + .passthrough(), +}); + +export type TBatchProcessorJobFinished = z.infer; + +export const downloadLinkSchema = z.object({ + download_link: z.string(), +}); + +export const testRequestSchema = z.object({ + test: z.enum(["test"]), +}); diff --git a/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts b/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts new file mode 100644 index 0000000000..ce072d175d --- /dev/null +++ b/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts @@ -0,0 +1,244 @@ +import { + createBookingScenario, + getScenarioData, + TestData, + getDate, + getMockBookingAttendee, + getOrganizer, + getBooker, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, afterEach, test, vi, beforeEach, beforeAll } from "vitest"; + +import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; +import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib"; +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient"; +import prisma from "@calcom/prisma"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { BookingStatus } from "@calcom/prisma/enums"; +import handler from "@calcom/web/pages/api/recorded-daily-video"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; +beforeAll(() => { + // Setup env vars + vi.stubEnv("SENDGRID_API_KEY", "FAKE_SENDGRID_API_KEY"); + vi.stubEnv("SENDGRID_EMAIL", "FAKE_SENDGRID_EMAIL"); +}); + +vi.mock("@calcom/app-store/dailyvideo/lib", () => { + return { + getRoomNameFromRecordingId: vi.fn(), + getBatchProcessorJobAccessLink: vi.fn(), + }; +}); + +vi.mock("@calcom/core/videoClient", () => { + return { + getDownloadLinkOfCalVideoByRecordingId: vi.fn(), + }; +}); + +const BATCH_PROCESSOR_JOB_FINSISHED_PAYLOAD = { + version: "1.1.0", + type: "batch-processor.job-finished", + id: "77b1cb9e-cd79-43cd-bad6-3ccaccba26be", + payload: { + id: "77b1cb9e-cd79-43cd-bad6-3ccaccba26be", + status: "finished", + input: { + sourceType: "recordingId", + recordingId: "eb9e84de-783e-4e14-875d-94700ee4b976", + }, + output: { + transcription: [ + { + format: "json", + s3Config: { + key: "transcript.json", + bucket: "daily-bucket", + region: "us-west-2", + }, + }, + { + format: "srt", + s3Config: { + key: "transcript.srt", + bucket: "daily-bucket", + region: "us-west-2", + }, + }, + { + format: "txt", + s3Config: { + key: "transcript.txt", + bucket: "daily-bucket", + region: "us-west-2", + }, + }, + { + format: "vtt", + s3Config: { + key: "transcript.vtt", + bucket: "daily-bucket", + region: "us-west-2", + }, + }, + ], + }, + }, + event_ts: 1717688213.803, +}; + +const timeout = process.env.CI ? 5000 : 20000; + +const TRANSCRIPTION_ACCESS_LINK = { + id: "MOCK_ID", + preset: "transcript", + status: "finished", + transcription: [ + { + format: "json", + link: "https://download.json", + }, + { + format: "srt", + link: "https://download.srt", + }, + ], +}; + +describe("Handler: /api/recorded-daily-video", () => { + beforeEach(() => { + fetchMock.resetMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + fetchMock.resetMocks(); + }); + + test( + `Batch Processor Job finished triggers RECORDING_TRANSCRIPTION_GENERATED webhooks`, + async () => { + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const bookingUid = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${bookingUid}@Cal.com`; + const subscriberUrl = "http://my-webhook.example.com"; + const recordingDownloadLink = "https://download-link.com"; + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: [WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 15, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + userId: organizer.id, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 2, + name: booker.name, + email: booker.email, + locale: "en", + timeZone: "Asia/Kolkata", + noShow: false, + }), + ], + iCalUID, + }, + ], + organizer, + apps: [TestData.apps["daily-video"]], + }) + ); + + vi.mocked(getRoomNameFromRecordingId).mockResolvedValue("MOCK_ID"); + vi.mocked(getBatchProcessorJobAccessLink).mockResolvedValue(TRANSCRIPTION_ACCESS_LINK); + vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({ + download_link: recordingDownloadLink, + }); + + const { req, res } = createMocks({ + method: "POST", + body: BATCH_PROCESSOR_JOB_FINSISHED_PAYLOAD, + prisma, + }); + + await handler(req, res); + + await expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, + payload: { + type: "Test Booking Title", + uid: bookingUid, + downloadLinks: { + transcription: TRANSCRIPTION_ACCESS_LINK.transcription, + recording: recordingDownloadLink, + }, + organizer: { + email: organizer.email, + name: organizer.name, + timeZone: organizer.timeZone, + language: { locale: "en" }, + utcOffset: 330, + }, + }, + }); + }, + timeout + ); +}); diff --git a/apps/web/lib/daily-webhook/triggerWebhooks.ts b/apps/web/lib/daily-webhook/triggerWebhooks.ts new file mode 100644 index 0000000000..562a8b70dc --- /dev/null +++ b/apps/web/lib/daily-webhook/triggerWebhooks.ts @@ -0,0 +1,112 @@ +import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod"; +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; +import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler:triggerRecordingReadyWebhook"] }); + +type Booking = { + userId: number | undefined; + eventTypeId: number | null; + eventTypeParentId: number | null | undefined; + teamId?: number | null; +}; + +const getWebhooksByEventTrigger = async (eventTrigger: WebhookTriggerEvents, booking: Booking) => { + const isTeamBooking = booking.teamId; + const isBookingForManagedEventtype = booking.teamId && booking.eventTypeParentId; + const triggerForUser = !isTeamBooking || isBookingForManagedEventtype; + const organizerUserId = triggerForUser ? booking.userId : null; + const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId }); + + const subscriberOptions = { + userId: organizerUserId, + eventTypeId: booking.eventTypeId, + triggerEvent: eventTrigger, + teamId: booking.teamId, + orgId, + }; + + return getWebhooks(subscriberOptions); +}; + +export const triggerRecordingReadyWebhook = async ({ + evt, + downloadLink, + booking, +}: { + evt: CalendarEvent; + downloadLink: string; + booking: Booking; +}) => { + const eventTrigger: WebhookTriggerEvents = "RECORDING_READY"; + const webhooks = await getWebhooksByEventTrigger(eventTrigger, booking); + + log.debug( + "Webhooks:", + safeStringify({ + webhooks, + }) + ); + + const promises = webhooks.map((webhook) => + sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { + ...evt, + downloadLink, + }).catch((e) => { + log.error( + `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`, + safeStringify(e) + ); + }) + ); + await Promise.all(promises); +}; + +export const triggerTranscriptionGeneratedWebhook = async ({ + evt, + downloadLinks, + booking, +}: { + evt: CalendarEvent; + downloadLinks?: { + transcription: TGetTranscriptAccessLink["transcription"]; + recording: string; + }; + booking: Booking; +}) => { + const webhooks = await getWebhooksByEventTrigger( + WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, + booking + ); + + log.debug( + "Webhooks:", + safeStringify({ + webhooks, + }) + ); + + const promises = webhooks.map((webhook) => + sendPayload( + webhook.secret, + WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED, + new Date().toISOString(), + webhook, + { + ...evt, + downloadLinks, + } + ).catch((e) => { + log.error( + `Error executing webhook for event: ${WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`, + safeStringify(e) + ); + }) + ); + await Promise.all(promises); +}; diff --git a/apps/web/pages/api/recorded-daily-video.ts b/apps/web/pages/api/recorded-daily-video.ts index 035f2317c7..ceab1c5ffc 100644 --- a/apps/web/pages/api/recorded-daily-video.ts +++ b/apps/web/pages/api/recorded-daily-video.ts @@ -1,8 +1,7 @@ -import type { WebhookTriggerEvents } from "@prisma/client"; import { createHmac } from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; +import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib"; import { getDownloadLinkOfCalVideoByRecordingId, submitBatchProcessorTranscriptionJob, @@ -10,210 +9,48 @@ import { import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient"; import { sendDailyVideoRecordingEmails } from "@calcom/emails"; import { sendDailyVideoTranscriptEmails } from "@calcom/emails"; -import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; -import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; -import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; +import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; 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"; +import prisma from "@calcom/prisma"; +import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking"; +import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference"; +import { getCalendarEvent } from "@calcom/web/lib/daily-webhook/getCalendarEvent"; +import { + meetingEndedSchema, + recordingReadySchema, + batchProcessorJobFinishedSchema, + downloadLinkSchema, + testRequestSchema, +} from "@calcom/web/lib/daily-webhook/schema"; +import { + triggerRecordingReadyWebhook, + triggerTranscriptionGeneratedWebhook, +} from "@calcom/web/lib/daily-webhook/triggerWebhooks"; const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] }); -const commonSchema = z - .object({ - version: z.string(), - type: z.string(), - id: z.string(), - event_ts: z.number().optional(), - }) - .passthrough(); - -const meetingEndedSchema = commonSchema.extend({ - payload: z - .object({ - meeting_id: z.string(), - end_ts: z.number().optional(), - room: z.string(), - start_ts: z.number().optional(), - }) - .passthrough(), -}); - -const recordingReadySchema = commonSchema.extend({ - payload: z.object({ - recording_id: z.string(), - end_ts: z.number().optional(), - room_name: z.string(), - start_ts: z.number().optional(), - status: z.string(), - - max_participants: z.number().optional(), - duration: z.number().optional(), - s3_key: z.string().optional(), - }), -}); - -const downloadLinkSchema = z.object({ - download_link: z.string(), -}); - -const triggerWebhook = async ({ - evt, - downloadLink, - booking, -}: { - evt: CalendarEvent; - downloadLink: string; - booking: { - userId: number | undefined; - eventTypeId: number | null; - eventTypeParentId: number | null | undefined; - teamId?: number | null; - }; -}) => { - const eventTrigger: WebhookTriggerEvents = "RECORDING_READY"; - - // Send Webhook call if hooked to BOOKING.RECORDING_READY - const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId); - - const organizerUserId = triggerForUser ? booking.userId : null; - - const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId }); - - const subscriberOptions = { - userId: organizerUserId, - eventTypeId: booking.eventTypeId, - triggerEvent: eventTrigger, - teamId: booking.teamId, - orgId, - }; - const webhooks = await getWebhooks(subscriberOptions); - - log.debug( - "Webhooks:", - safeStringify({ - webhooks, - }) - ); - - const promises = webhooks.map((webhook) => - sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { - ...evt, - downloadLink, - }).catch((e) => { - log.error( - `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`, - safeStringify(e) - ); - }) - ); - await Promise.all(promises); +const computeSignature = ( + hmacSecret: string, + reqBody: NextApiRequest["body"], + webhookTimestampHeader: string | string[] | undefined +) => { + const signature = `${webhookTimestampHeader}.${JSON.stringify(reqBody)}`; + const base64DecodedSecret = Buffer.from(hmacSecret, "base64"); + const hmac = createHmac("sha256", base64DecodedSecret); + const computed_signature = hmac.update(signature).digest("base64"); + return computed_signature; }; -const getBooking = async (bookingId: number) => { - const booking = await prisma.booking.findUniqueOrThrow({ - where: { - id: bookingId, - }, - select: { - ...bookingMinimalSelect, - uid: true, - location: true, - isRecorded: true, - eventTypeId: true, - eventType: { - select: { - teamId: true, - parentId: true, - }, - }, - user: { - select: { - id: true, - timeZone: true, - email: true, - name: true, - locale: true, - destinationCalendar: true, - }, - }, - }, - }); - - if (!booking) { - log.error( - "Couldn't find Booking Id:", - safeStringify({ - bookingId, - }) - ); - } - return booking; +const getDownloadLinkOfCalVideo = async (recordingId: string) => { + const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId); + const downloadLinkResponse = downloadLinkSchema.parse(response); + const downloadLink = downloadLinkResponse.download_link; + return downloadLink; }; -const getBookingReference = async (roomName: string) => { - const bookingReference = await prisma.bookingReference.findFirst({ - where: { type: "daily_video", uid: roomName, meetingId: roomName }, - select: { bookingId: true }, - }); - - if (!bookingReference) { - log.error( - "bookingReference not found error:", - safeStringify({ - bookingReference, - roomName, - }) - ); - } - - return bookingReference; -}; - -const getCalendarEvent = async (booking: Awaited>) => { - const t = await getTranslation(booking?.user?.locale ?? "en", "common"); - - const attendeesListPromises = booking.attendees.map(async (attendee) => { - return { - id: attendee.id, - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { - translate: await getTranslation(attendee.locale ?? "en", "common"), - locale: attendee.locale ?? "en", - }, - }; - }); - - const attendeesList = await Promise.all(attendeesListPromises); - const evt: CalendarEvent = { - type: booking.title, - title: booking.title, - description: booking.description || undefined, - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - organizer: { - email: booking?.userPrimaryEmail || booking.user?.email || "Email-less", - name: booking.user?.name || "Nameless", - timeZone: booking.user?.timeZone || "Europe/London", - language: { translate: t, locale: booking?.user?.locale ?? "en" }, - }, - attendees: attendeesList, - uid: booking.uid, - }; - - return Promise.resolve(evt); -}; - -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" }); @@ -223,18 +60,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { 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 testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE; - 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 (!testMode) { + const hmacSecret = process.env.DAILY_WEBHOOK_SECRET; + if (!hmacSecret) { + return res.status(405).json({ message: "No Daily Webhook Secret" }); + } - if (req.headers["x-webhook-signature"] !== computed_signature) { - return res.status(403).json({ message: "Signature does not match" }); + const computed_signature = computeSignature(hmacSecret, req.body, req.headers["x-webhook-timestamp"]); + + if (req.headers["x-webhook-signature"] !== computed_signature) { + return res.status(403).json({ message: "Signature does not match" }); + } } log.debug( @@ -261,19 +99,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { message: "Recording not finished", }); } + const bookingReference = await getBookingReference(room_name); - - if (!bookingReference || !bookingReference.bookingId) { - return res.status(200).send({ message: "Booking reference not found" }); - } - - const booking = await getBooking(bookingReference.bookingId); - - if (!booking) { - return res.status(404).send({ - message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`, - }); - } + const booking = await getBooking(bookingReference.bookingId as number); const evt = await getCalendarEvent(booking); @@ -286,9 +114,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }); - const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id); - const downloadLinkResponse = downloadLinkSchema.parse(response); - const downloadLink = downloadLinkResponse.download_link; + const downloadLink = await getDownloadLinkOfCalVideo(recording_id); const teamId = await getTeamIdFromEventType({ eventType: { @@ -297,7 +123,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }); - await triggerWebhook({ + await triggerRecordingReadyWebhook({ evt, downloadLink, booking: { @@ -317,6 +143,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { // send emails to all attendees only when user has team plan await sendDailyVideoRecordingEmails(evt, downloadLink); + return res.status(200).json({ message: "Success" }); } else if (req.body.type === "meeting.ended") { const meetingEndedResponse = meetingEndedSchema.safeParse(req.body); @@ -325,20 +152,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { message: "Invalid Payload", }); } + const { room } = meetingEndedResponse.data.payload; const bookingReference = await getBookingReference(room); - if (!bookingReference || !bookingReference.bookingId) { - return res.status(200).send({ message: "Booking reference not found" }); - } - - const booking = await getBooking(bookingReference.bookingId); - - if (!booking) { - return res.status(404).send({ - message: `Booking of room_name ${room} does not exist or does not contain daily video as location`, - }); - } + const booking = await getBooking(bookingReference.bookingId as number); const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room); @@ -349,10 +167,62 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { await sendDailyVideoTranscriptEmails(evt, transcripts); return res.status(200).json({ message: "Success" }); + } else if (req.body?.type === "batch-processor.job-finished") { + const batchProcessorJobFinishedResponse = batchProcessorJobFinishedSchema.safeParse(req.body); + + if (!batchProcessorJobFinishedResponse.success) { + return res.status(400).send({ + message: "Invalid Payload", + }); + } + + const { id, input } = batchProcessorJobFinishedResponse.data.payload; + const roomName = await getRoomNameFromRecordingId(input.recordingId); + + const bookingReference = await getBookingReference(roomName); + + const booking = await getBooking(bookingReference.bookingId as number); + + const teamId = await getTeamIdFromEventType({ + eventType: { + team: { id: booking?.eventType?.teamId ?? null }, + parentId: booking?.eventType?.parentId ?? null, + }, + }); + + const evt = await getCalendarEvent(booking); + + const recording = await getDownloadLinkOfCalVideo(input.recordingId); + const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id); + + await triggerTranscriptionGeneratedWebhook({ + evt, + downloadLinks: { + transcription: batchProcessorJobAccessLink.transcription, + recording, + }, + booking: { + userId: booking?.user?.id, + eventTypeId: booking.eventTypeId, + eventTypeParentId: booking.eventType?.parentId, + teamId, + }, + }); + + return res.status(200).json({ message: "Success" }); + } else { + log.error("Invalid type in /recorded-daily-video", req.body); + + return res.status(200).json({ message: "Invalid type in /recorded-daily-video" }); } } catch (err) { log.error("Error in /recorded-daily-video", err); - return res.status(500).json({ message: "something went wrong" }); + + if (err instanceof HttpError) { + return res.status(err.statusCode).json({ message: err.message }); + } else { + return res.status(500).json({ message: "something went wrong" }); + } } } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 33a7c693bc..f04598e37f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -464,6 +464,7 @@ "booking_cancelled": "Booking Canceled", "booking_rescheduled": "Booking Rescheduled", "recording_ready": "Recording Download Link Ready", + "recording_transcription_generated": "Transcript Generated", "booking_created": "Booking Created", "booking_rejected": "Booking Rejected", "booking_requested": "Booking Requested", diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 28023b6a85..f9b3cef1df 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -74,6 +74,16 @@ function postToDailyAPI(endpoint: string, body: Record) { }); } +export const getBatchProcessorJobAccessLink = (id: string) => { + return fetcher(`/batch-processor/${id}/access-link`).then(ZGetTranscriptAccessLink.parse); +}; + +export const getRoomNameFromRecordingId = (recordingId: string) => { + return fetcher(`/recordings/${recordingId}`) + .then(recordingItemSchema.parse) + .then((res) => res.room_name); +}; + async function processTranscriptsInBatches(transcriptIds: Array) { const batchSize = 5; // Batch size const batches = []; // Array to hold batches of transcript IDs @@ -332,9 +342,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { if (!transcriptJobId) return []; - const accessLinkRes = await fetcher(`/batch-processor/${transcriptJobId}/access-link`).then( - ZGetTranscriptAccessLink.parse - ); + const accessLinkRes = await getBatchProcessorJobAccessLink(transcriptJobId); return accessLinkRes.transcription; } catch (err) { diff --git a/packages/app-store/dailyvideo/lib/index.ts b/packages/app-store/dailyvideo/lib/index.ts index dc61768d60..892e43b077 100644 --- a/packages/app-store/dailyvideo/lib/index.ts +++ b/packages/app-store/dailyvideo/lib/index.ts @@ -1 +1,2 @@ export { default as VideoApiAdapter } from "./VideoApiAdapter"; +export { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "./VideoApiAdapter"; diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx index 90116704c7..b8d58c8675 100644 --- a/packages/features/webhooks/components/WebhookForm.tsx +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -43,6 +43,10 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record()({ + id: true, + type: true, + uid: true, + meetingId: true, + meetingUrl: true, + credentialId: true, + deleted: true, + bookingId: true, +}); + +export class BookingReferenceRepository { + static async findDailyVideoReferenceByRoomName({ roomName }: { roomName: string }) { + return prisma.bookingReference.findFirst({ + where: { type: "daily_video", uid: roomName, meetingId: roomName, bookingId: { not: null } }, + select: bookingReferenceSelect, + }); + } +} diff --git a/packages/prisma/migrations/20240626191137_add_recording_transcription_ready_webhook/migration.sql b/packages/prisma/migrations/20240626191137_add_recording_transcription_ready_webhook/migration.sql new file mode 100644 index 0000000000..d418ff70d2 --- /dev/null +++ b/packages/prisma/migrations/20240626191137_add_recording_transcription_ready_webhook/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'RECORDING_TRANSCRIPTION_GENERATED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index de563d1268..e747fc75bd 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -714,6 +714,7 @@ enum WebhookTriggerEvents { MEETING_STARTED RECORDING_READY INSTANT_MEETING + RECORDING_TRANSCRIPTION_GENERATED } model Webhook {