first commit
This commit is contained in:
55
calcom/apps/web/lib/daily-webhook/getBooking.ts
Normal file
55
calcom/apps/web/lib/daily-webhook/getBooking.ts
Normal file
@@ -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>>;
|
||||
24
calcom/apps/web/lib/daily-webhook/getBookingReference.ts
Normal file
24
calcom/apps/web/lib/daily-webhook/getBookingReference.ts
Normal file
@@ -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;
|
||||
};
|
||||
40
calcom/apps/web/lib/daily-webhook/getCalendarEvent.ts
Normal file
40
calcom/apps/web/lib/daily-webhook/getCalendarEvent.ts
Normal file
@@ -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);
|
||||
};
|
||||
63
calcom/apps/web/lib/daily-webhook/schema.ts
Normal file
63
calcom/apps/web/lib/daily-webhook/schema.ts
Normal file
@@ -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
|
||||
);
|
||||
});
|
||||
112
calcom/apps/web/lib/daily-webhook/triggerWebhooks.ts
Normal file
112
calcom/apps/web/lib/daily-webhook/triggerWebhooks.ts
Normal file
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user