feat: RECORDING_TRANSCRIPTION_GENERATED webhook (#15589)
* feat: RECORDING_TRANSCRIPTION_READY webhook * refactor: split into diff files and restructuring * feat: finish batch processor finished * chore: types * fix: type error * chore: change name of triggger * test: add unit test * fix: type * fix: type err * chore: test * feat: add booking reference repository and other imp * chore: log * fix: import * chore: type error * fix: update test * fix: test * fix: test * fix: add timeout * chore: move beforeEach * Mock missing env variables to test. These vars are not set in CI --------- Co-authored-by: Hariom <hariombalhara@gmail.com>
This commit is contained in:
1 parent
7d06d9f865
commit
884ccc9b48
16 files changed
+694
-236
No files matched your search
@@ -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<ReturnType<typeof getBooking>>;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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<typeof batchProcessorJobFinishedSchema>;
|
||||
|
||||
export const downloadLinkSchema = z.object({
|
||||
download_link: z.string(),
|
||||
});
|
||||
|
||||
export const testRequestSchema = z.object({
|
||||
test: z.enum(["test"]),
|
||||
});
|
||||
@@ -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<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
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
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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<ReturnType<typeof getBooking>>) => {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -74,6 +74,16 @@ function postToDailyAPI(endpoint: string, body: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
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<string>) {
|
||||
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) {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as VideoApiAdapter } from "./VideoApiAdapter";
|
||||
export { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "./VideoApiAdapter";
|
||||
@@ -43,6 +43,10 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
|
||||
{ value: WebhookTriggerEvents.MEETING_STARTED, label: "meeting_started" },
|
||||
{ value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" },
|
||||
{ value: WebhookTriggerEvents.INSTANT_MEETING, label: "instant_meeting" },
|
||||
{
|
||||
value: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
|
||||
label: "recording_transcription_generated",
|
||||
},
|
||||
],
|
||||
"routing-forms": [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }],
|
||||
} as const;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
|
||||
WebhookTriggerEvents.BOOKING_REJECTED,
|
||||
WebhookTriggerEvents.RECORDING_READY,
|
||||
WebhookTriggerEvents.INSTANT_MEETING,
|
||||
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
|
||||
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
|
||||
] as const,
|
||||
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Webhook } from "@prisma/client";
|
||||
import { createHmac } from "crypto";
|
||||
import { compile } from "handlebars";
|
||||
|
||||
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
|
||||
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
|
||||
import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
@@ -36,7 +37,15 @@ export type BookingNoShowUpdatedPayload = {
|
||||
attendees: { email: string; noShow: boolean }[];
|
||||
};
|
||||
|
||||
export type TranscriptionGeneratedPayload = {
|
||||
downloadLinks?: {
|
||||
transcription: TGetTranscriptAccessLink["transcription"];
|
||||
recording: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebhookDataType = CalendarEvent &
|
||||
TranscriptionGeneratedPayload &
|
||||
// BookingNoShowUpdatedPayload & // This breaks all other webhooks
|
||||
EventTypeInfo & {
|
||||
metadata?: { [key: string]: string | number | boolean | null };
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const bookingReferenceSelect = Prisma.validator<Prisma.BookingReferenceSelect>()({
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'RECORDING_TRANSCRIPTION_GENERATED';
|
||||
@@ -714,6 +714,7 @@ enum WebhookTriggerEvents {
|
||||
MEETING_STARTED
|
||||
RECORDING_READY
|
||||
INSTANT_MEETING
|
||||
RECORDING_TRANSCRIPTION_GENERATED
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
|
||||
Reference in new issue
Block a user