diff --git a/apps/web/pages/api/book/instant-event.ts b/apps/web/pages/api/book/instant-event.ts index f809795c10c744..5ffec4ed6533d5 100644 --- a/apps/web/pages/api/book/instant-event.ts +++ b/apps/web/pages/api/book/instant-event.ts @@ -25,6 +25,10 @@ async function handler(req: NextApiRequest & { userId?: number }) { // TODO: We should do the run-time schema validation here and pass a typed bookingData instead and then run-time schema could be removed from createBooking. Then we can remove the any type from req.body. const booking = await instantBookingService.createBooking({ bookingData: req.body, + bookingMeta: { + userUuid: session?.user?.uuid, + impersonatedByUserUuid: null, + }, }); return booking; diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index 83106534f240dc..5aee1b2b388b42 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -80,8 +80,11 @@ export class CreatedAuditActionService implements IAuditActionService { async getDisplayTitle({ storedData, dbStore }: GetDisplayTitleParams): Promise { const { fields } = this.parseStored(storedData); + if (fields.status === BookingStatus.AWAITING_HOST) { + return { key: "booking_audit_action.created_awaiting_host", params: {} }; + } const hostUser = fields.hostUserUuid ? dbStore.getUserByUuid(fields.hostUserUuid) : null; - const hostName = hostUser?.name || "Unknown"; + const hostName = hostUser?.name ?? "Unknown"; if (fields.seatReferenceUid) { return { key: "booking_audit_action.created_with_seat", params: { host: hostName } }; } diff --git a/packages/features/bookings/di/InstantBookingCreateService.module.ts b/packages/features/bookings/di/InstantBookingCreateService.module.ts index 741615330da87f..bd3d25d17ef1dc 100644 --- a/packages/features/bookings/di/InstantBookingCreateService.module.ts +++ b/packages/features/bookings/di/InstantBookingCreateService.module.ts @@ -1,5 +1,7 @@ import { InstantBookingCreateService } from "@calcom/features/bookings/lib/service/InstantBookingCreateService"; +import { moduleLoader as bookingEventHandlerModuleLoader } from "@calcom/features/bookings/di/BookingEventHandlerService.module"; import { createModule, bindModuleToClassOnToken } from "@calcom/features/di/di"; +import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; @@ -14,6 +16,8 @@ const loadModule = bindModuleToClassOnToken({ depsMap: { // TODO: In a followup PR, we aim to remove prisma dependency and instead inject the repositories as dependencies. prismaClient: prismaModuleLoader, + bookingEventHandler: bookingEventHandlerModuleLoader, + featuresRepository: featuresRepositoryModuleLoader, }, }); diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.test.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.test.ts index 73574053e6f5bb..25d6a2606e0f3f 100644 --- a/packages/features/bookings/lib/service/InstantBookingCreateService.test.ts +++ b/packages/features/bookings/lib/service/InstantBookingCreateService.test.ts @@ -178,6 +178,168 @@ describe("handleInstantMeeting", () => { }) ).rejects.toThrow("Only Team Event Types are supported for Instant Meeting"); }); + + it("should fire booking audit event with correct data when org has booking-audit feature", async () => { + const { BookingEventHandlerService } = await import("../onBookingEvents/BookingEventHandlerService"); + const onBookingCreatedSpy = vi + .spyOn(BookingEventHandlerService.prototype, "onBookingCreated") + .mockResolvedValue(undefined); + + const instantBookingCreateService = getInstantBookingCreateService(); + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [{ id: 101 }], + team: { id: 1 }, + instantMeetingExpiryTimeOffsetInSeconds: 90, + }, + ], + organizer, + apps: [TestData.apps["daily-video"], TestData.apps["google-calendar"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID" }, + }); + + const mockBookingData: CreateInstantBookingData = { + eventTypeId: 1, + timeZone: "UTC", + language: "en", + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:45:00.000Z`, + responses: { + name: "Test User", + email: "test@example.com", + attendeePhoneNumber: "+918888888888", + }, + metadata: {}, + instant: true, + }; + + const result = await instantBookingCreateService.createBooking({ + bookingData: mockBookingData, + }); + + expect(result.message).toBe("Success"); + expect(result.bookingId).toBeDefined(); + + expect(onBookingCreatedSpy).toHaveBeenCalledTimes(1); + + const callArgs = onBookingCreatedSpy.mock.calls[0][0]; + expect(callArgs.payload.booking.uid).toBe(result.bookingUid); + expect(callArgs.payload.config.isDryRun).toBe(false); + expect(callArgs.actor).toEqual( + expect.objectContaining({ identifiedBy: expect.any(String) }) + ); + expect(callArgs.auditData).toEqual( + expect.objectContaining({ + startTime: expect.any(Number), + endTime: expect.any(Number), + status: expect.any(String), + }) + ); + expect(callArgs.source).toEqual(expect.any(String)); + + onBookingCreatedSpy.mockRestore(); + }); + + it("should not throw when booking audit event fails", async () => { + const { BookingEventHandlerService } = await import("../onBookingEvents/BookingEventHandlerService"); + const onBookingCreatedSpy = vi + .spyOn(BookingEventHandlerService.prototype, "onBookingCreated") + .mockRejectedValue(new Error("Audit event handler failure")); + + const instantBookingCreateService = getInstantBookingCreateService(); + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [{ id: 101 }], + team: { id: 1 }, + instantMeetingExpiryTimeOffsetInSeconds: 90, + }, + ], + organizer, + apps: [TestData.apps["daily-video"], TestData.apps["google-calendar"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID" }, + }); + + const mockBookingData: CreateInstantBookingData = { + eventTypeId: 1, + timeZone: "UTC", + language: "en", + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:45:00.000Z`, + responses: { + name: "Test User", + email: "test@example.com", + attendeePhoneNumber: "+918888888888", + }, + metadata: {}, + instant: true, + }; + + const result = await instantBookingCreateService.createBooking({ + bookingData: mockBookingData, + }); + + expect(result.message).toBe("Success"); + expect(result.bookingId).toBeDefined(); + expect(onBookingCreatedSpy).toHaveBeenCalled(); + + onBookingCreatedSpy.mockRestore(); + }); }); }); diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.ts index 797c2ab6a61934..a732b599aca189 100644 --- a/packages/features/bookings/lib/service/InstantBookingCreateService.ts +++ b/packages/features/bookings/lib/service/InstantBookingCreateService.ts @@ -1,45 +1,54 @@ import { randomBytes } from "node:crypto"; -import short from "short-uuid"; -import { v5 as uuidv5 } from "uuid"; - import dayjs from "@calcom/dayjs"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import type { + CreateBookingMeta, CreateInstantBookingData, InstantBookingCreateResult, } from "@calcom/features/bookings/lib/dto/types"; import getBookingDataSchema from "@calcom/features/bookings/lib/getBookingDataSchema"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; +import { buildBookingCreatedAuditData } from "@calcom/features/bookings/lib/handleNewBooking/buildBookingEventAuditData"; +import { getAuditActionSource } from "@calcom/features/bookings/lib/handleNewBooking/getAuditActionSource"; +import { getBookingAuditActorForNewBooking } from "@calcom/features/bookings/lib/handleNewBooking/getBookingAuditActorForNewBooking"; import { getBookingData } from "@calcom/features/bookings/lib/handleNewBooking/getBookingData"; import { getCustomInputsResponses } from "@calcom/features/bookings/lib/handleNewBooking/getCustomInputsResponses"; import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; import type { IBookingCreateService } from "@calcom/features/bookings/lib/interfaces/IBookingCreateService"; +import type { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; import { createInstantMeetingWithCalVideo } from "@calcom/features/conferencing/lib/videoClient"; +import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getFullName } from "@calcom/features/form-builder/utils"; import { sendNotification } from "@calcom/features/notifications/sendNotification"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import { getTranslation } from "@calcom/i18n/server"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { ErrorWithCode } from "@calcom/lib/errors"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import logger from "@calcom/lib/logger"; -import { getTranslation } from "@calcom/i18n/server"; import type { PrismaClient } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; -import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; - +import { BookingStatus, type CreationSource, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import short from "short-uuid"; +import { v5 as uuidv5 } from "uuid"; +import type { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; import { instantMeetingSubscriptionSchema as subscriptionSchema } from "../dto/schema"; -import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; interface IInstantBookingCreateServiceDependencies { prismaClient: PrismaClient; + bookingEventHandler: BookingEventHandlerService; + featuresRepository: FeaturesRepository; } const handleInstantMeetingWebhookTrigger = async (args: { eventTypeId: number; webhookData: Record; teamId: number; + orgId: number; prismaClient: PrismaClient; }) => { - const orgId = (await getOrgIdFromMemberOrTeamId({ teamId: args.teamId })) ?? 0; + const orgId = args.orgId; const { prismaClient: prisma } = args; try { const eventTrigger = WebhookTriggerEvents.INSTANT_MEETING; @@ -78,7 +87,7 @@ const handleInstantMeetingWebhookTrigger = async (args: { const { webhookData } = args; const promises = subscribers.map((sub) => { - sendGenericWebhookPayload({ + return sendGenericWebhookPayload({ secretKey: sub.secret, triggerEvent: eventTrigger, createdAt: new Date().toISOString(), @@ -167,8 +176,9 @@ const triggerBrowserNotifications = async (args: { }; export async function handler( - bookingData: CreateInstantBookingData, - deps: IInstantBookingCreateServiceDependencies + bookingData: CreateInstantBookingData & { creationSource: CreationSource }, + deps: IInstantBookingCreateServiceDependencies, + bookingMeta?: CreateBookingMeta ) { // TODO: In a followup PR, we aim to remove prisma dependency and instead inject the repositories as dependencies. const { prismaClient: prisma } = deps; @@ -180,9 +190,12 @@ export async function handler( }; if (!eventType.team?.id) { - throw new Error("Only Team Event Types are supported for Instant Meeting"); + throw ErrorWithCode.Factory.BadRequest("Only Team Event Types are supported for Instant Meeting"); } + const creationSource = bookingData.creationSource; + const userUuid = bookingMeta?.userUuid ?? null; + const schema = getBookingDataSchema({ view: bookingData?.rescheduleUid ? "reschedule" : "booking", bookingFields: eventType.bookingFields, @@ -243,7 +256,7 @@ export async function handler( const calVideoMeeting = await createInstantMeetingWithCalVideo(dayjs.utc(reqBody.end).toISOString()); if (!calVideoMeeting) { - throw new Error("Cal Video Meeting Creation Failed"); + throw ErrorWithCode.Factory.InternalServerError("Cal Video Meeting Creation Failed"); } const bookingReferenceToCreate = [ @@ -281,12 +294,15 @@ export async function handler( data: attendeesList, }, }, - creationSource: bookingData.creationSource, + creationSource, }; const createBookingObj = { include: { attendees: true, + user: { + select: { uuid: true }, + }, }, data: newBookingData, }; @@ -319,6 +335,7 @@ export async function handler( }); // Trigger Webhook + const orgId = (await getOrgIdFromMemberOrTeamId({ teamId: eventType.team?.id })) ?? null; const webhookData = { triggerEvent: WebhookTriggerEvents.INSTANT_MEETING, uid: newBooking.uid, @@ -333,6 +350,7 @@ export async function handler( eventTypeId: eventType.id, webhookData, teamId: eventType.team?.id, + orgId: orgId ?? 0, prismaClient: prisma, }); @@ -343,6 +361,18 @@ export async function handler( prismaClient: prisma, }); + await fireBookingEvents({ + booking: newBooking, + bookerEmail, + bookerName: fullName, + eventType, + creationSource, + orgId, + hostUserUuid: newBooking.user?.uuid ?? null, + userUuid: userUuid ?? null, + deps, + }); + return { message: "Success", meetingTokenId: instantMeetingToken.id, @@ -353,13 +383,97 @@ export async function handler( } satisfies InstantBookingCreateResult; } +async function fireBookingEvents({ + booking, + bookerEmail, + bookerName, + eventType, + creationSource, + orgId, + hostUserUuid, + userUuid, + deps, +}: { + booking: { + uid: string; + startTime: Date; + endTime: Date; + status: BookingStatus; + userId: number | null; + attendees?: Array<{ id: number; email: string }>; + }; + bookerEmail: string; + bookerName: string; + eventType: { id: number }; + creationSource: CreationSource; + orgId: number | null; + hostUserUuid: string | null; + userUuid: string | null; + deps: IInstantBookingCreateServiceDependencies; +}) { + try { + const isBookingAuditEnabled = orgId + ? await deps.featuresRepository.checkIfTeamHasFeature(orgId, "booking-audit") + : false; + + const actionSource: ActionSource = getAuditActionSource({ + creationSource, + eventTypeId: eventType.id, + rescheduleUid: null, + }); + + const bookerAttendeeId = booking.attendees?.find((a) => a.email === bookerEmail)?.id ?? null; + const auditActor = getBookingAuditActorForNewBooking({ + bookerAttendeeId, + actorUserUuid: userUuid, + bookerEmail, + bookerName, + rescheduledBy: null, + logger, + }); + + await deps.bookingEventHandler.onBookingCreated({ + payload: { + config: { isDryRun: false }, + bookingFormData: { hashedLink: null }, + booking: { + uid: booking.uid, + startTime: booking.startTime, + endTime: booking.endTime, + status: booking.status, + userId: booking.userId, + }, + organizationId: orgId, + }, + actor: auditActor, + auditData: buildBookingCreatedAuditData({ + booking: { + startTime: booking.startTime, + endTime: booking.endTime, + status: booking.status, + userUuid: hostUserUuid, + }, + attendeeSeatId: null, + }), + source: actionSource, + operationId: null, + isBookingAuditEnabled, + }); + } catch (error) { + logger.error("Error firing booking audit event for instant booking", error); + } +} + /** * Instant booking service that handles instant/immediate bookings */ export class InstantBookingCreateService implements IBookingCreateService { constructor(private readonly deps: IInstantBookingCreateServiceDependencies) {} - async createBooking(input: { bookingData: CreateInstantBookingData }): Promise { - return handler(input.bookingData, this.deps); + async createBooking(input: { + bookingData: CreateInstantBookingData & { creationSource: CreationSource }; + bookingMeta?: CreateBookingMeta; + }): Promise { + return handler(input.bookingData, this.deps, input.bookingMeta); } } diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index ef962e53778e2c..515683022c646c 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -4568,6 +4568,7 @@ "location_applied_to_hosts_other": "Location applied to {{count}} hosts", "booking_audit_action": { "created": "Booked with {{host}}", + "created_awaiting_host": "Booked (awaiting host)", "created_with_seat": "Seat Booked with {{host}}", "cancelled": "Cancelled", "rescheduled": "Rescheduled {{oldDate}} -> <0>{{newDate}}", diff --git a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.test.ts b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.test.ts new file mode 100644 index 00000000000000..a1a09283ebb79f --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.test.ts @@ -0,0 +1,208 @@ +import { prisma } from "@calcom/prisma/__mocks__/prisma"; + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { BookingStatus } from "@calcom/prisma/enums"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +import { Handler } from "./connectAndJoin.handler"; + +vi.mock("@calcom/prisma", () => ({ + prisma, +})); + +vi.mock("@calcom/emails/email-manager", () => ({ + sendScheduledEmailsAndSMS: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@calcom/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers", () => ({ + scheduleNoShowTriggers: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@calcom/i18n/server", () => ({ + getTranslation: vi.fn().mockResolvedValue((key: string) => key), +})); + +vi.mock("@calcom/features/eventtypes/di/EventTypeService.container", () => ({ + getEventTypeService: vi.fn(() => ({ + shouldHideBrandingForEventType: vi.fn().mockResolvedValue(false), + })), +})); + +vi.mock("@calcom/features/bookings/lib/getCalEventResponses", () => ({ + getCalEventResponses: vi.fn().mockReturnValue({}), +})); + +vi.mock("@calcom/features/di/containers/FeaturesRepository"); +vi.mock("@calcom/features/bookings/di/BookingEventHandlerService.container"); + +const MOCK_BOOKING_UID = "booking-uid-123"; +const MOCK_USER_UUID = "user-uuid-456"; +const MOCK_ORG_ID = 100; +const MOCK_TOKEN = "instant-meeting-token"; + +function createMockUser(overrides: Partial> = {}) { + return { + id: 1, + uuid: MOCK_USER_UUID, + email: "host@example.com", + name: "Host User", + username: "hostuser", + timeZone: "UTC", + timeFormat: 12, + locale: "en", + organization: { id: MOCK_ORG_ID }, + organizationId: MOCK_ORG_ID, + ...overrides, + } as unknown as NonNullable; +} + +function mockPrismaForSuccessfulJoin({ oldStatus = BookingStatus.AWAITING_HOST } = {}) { + prisma.user.findUnique.mockResolvedValue({ + hideBranding: false, + profiles: [], + } as any); + + prisma.instantMeetingToken.findUnique.mockResolvedValue({ + expires: new Date(Date.now() + 60_000), + teamId: 1, + booking: { + id: 10, + status: oldStatus, + user: { id: 99 }, + }, + } as any); + + prisma.booking.update.mockResolvedValue({ + id: 10, + uid: MOCK_BOOKING_UID, + title: "Instant Meeting", + description: null, + customInputs: {}, + startTime: new Date("2026-04-04T10:00:00Z"), + endTime: new Date("2026-04-04T10:45:00Z"), + location: "integrations:daily", + userId: 1, + status: BookingStatus.ACCEPTED, + responses: {}, + metadata: { videoCallUrl: "https://daily.co/mock-meeting" }, + attendees: [ + { + name: "Attendee", + email: "attendee@example.com", + timeZone: "UTC", + locale: "en", + }, + ], + references: [ + { + type: "daily_video", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "https://daily.co/mock-meeting", + }, + ], + eventTypeId: 1, + eventType: { + id: 1, + owner: null, + teamId: 1, + title: "Instant Meeting", + slug: "instant-meeting", + requiresConfirmation: false, + currency: "usd", + length: 45, + description: null, + price: 0, + bookingFields: null, + disableGuests: false, + metadata: null, + hideOrganizerEmail: false, + customInputs: [], + parentId: null, + customReplyToEmail: null, + team: { + id: 1, + name: "Test Team", + hideBranding: false, + parent: null, + }, + }, + } as any); +} + +describe("connectAndJoin.handler", () => { + const mockOnBookingAccepted = vi.fn().mockResolvedValue(undefined); + const mockCheckIfTeamHasFeature = vi.fn(); + + beforeEach(async () => { + vi.clearAllMocks(); + + const { getBookingEventHandlerService } = await import( + "@calcom/features/bookings/di/BookingEventHandlerService.container" + ); + vi.mocked(getBookingEventHandlerService).mockReturnValue({ + onBookingAccepted: mockOnBookingAccepted, + } as any); + + const { getFeaturesRepository } = await import("@calcom/features/di/containers/FeaturesRepository"); + vi.mocked(getFeaturesRepository).mockReturnValue({ + checkIfTeamHasFeature: mockCheckIfTeamHasFeature, + } as any); + }); + + describe("booking audit event", () => { + it("should fire booking accepted audit event with correct data", async () => { + mockCheckIfTeamHasFeature.mockResolvedValue(true); + mockPrismaForSuccessfulJoin({ oldStatus: BookingStatus.AWAITING_HOST }); + + await Handler({ + ctx: { user: createMockUser() }, + input: { token: MOCK_TOKEN }, + }); + + expect(mockCheckIfTeamHasFeature).toHaveBeenCalledWith(MOCK_ORG_ID, "booking-audit"); + + expect(mockOnBookingAccepted).toHaveBeenCalledTimes(1); + expect(mockOnBookingAccepted).toHaveBeenCalledWith({ + bookingUid: MOCK_BOOKING_UID, + actor: { identifiedBy: "user", userUuid: MOCK_USER_UUID }, + organizationId: MOCK_ORG_ID, + auditData: { + status: { old: BookingStatus.AWAITING_HOST, new: BookingStatus.ACCEPTED }, + }, + source: "WEBAPP", + isBookingAuditEnabled: true, + }); + }); + + it("should pass isBookingAuditEnabled=false when feature is disabled", async () => { + mockCheckIfTeamHasFeature.mockResolvedValue(false); + mockPrismaForSuccessfulJoin(); + + await Handler({ + ctx: { user: createMockUser() }, + input: { token: MOCK_TOKEN }, + }); + + expect(mockOnBookingAccepted).toHaveBeenCalledTimes(1); + expect(mockOnBookingAccepted).toHaveBeenCalledWith( + expect.objectContaining({ isBookingAuditEnabled: false }) + ); + }); + + it("should not throw when audit event fails", async () => { + mockCheckIfTeamHasFeature.mockResolvedValue(true); + mockOnBookingAccepted.mockRejectedValue(new Error("Audit handler failure")); + mockPrismaForSuccessfulJoin(); + + const result = await Handler({ + ctx: { user: createMockUser() }, + input: { token: MOCK_TOKEN }, + }); + + expect(result.meetingUrl).toBe("https://daily.co/mock-meeting"); + expect(mockOnBookingAccepted).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts index 51766b947db48b..9a7479223b4642 100644 --- a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts @@ -1,12 +1,15 @@ import { sendScheduledEmailsAndSMS } from "@calcom/emails/email-manager"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { scheduleNoShowTriggers } from "@calcom/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers"; +import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository"; import { type EventTypeBrandingData, getEventTypeService, } from "@calcom/features/eventtypes/di/EventTypeService.container"; -import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import { getTranslation } from "@calcom/i18n/server"; +import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; +import logger from "@calcom/lib/logger"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -163,6 +166,13 @@ export const Handler = async ({ ctx, input }: Options) => { }, }); + await fireBookingAcceptedAuditEvent({ + bookingUid: updatedBooking.uid, + actorUserUuid: user.uuid, + organizationId: user.organizationId, + oldStatus: instantMeetingToken.booking.status, + }); + const locationVideoCallUrl = bookingMetadataSchema.parse(updatedBooking.metadata || {})?.videoCallUrl; if (!locationVideoCallUrl) { @@ -282,3 +292,36 @@ export const Handler = async ({ ctx, input }: Options) => { return { isBookingAlreadyAcceptedBySomeoneElse, meetingUrl: locationVideoCallUrl }; }; + +async function fireBookingAcceptedAuditEvent({ + bookingUid, + actorUserUuid, + organizationId, + oldStatus, +}: { + bookingUid: string; + actorUserUuid: string; + organizationId: number | null; + oldStatus: BookingStatus; +}) { + try { + const featuresRepository = getFeaturesRepository(); + const isBookingAuditEnabled = organizationId + ? await featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit") + : false; + + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onBookingAccepted({ + bookingUid, + actor: { identifiedBy: "user", userUuid: actorUserUuid }, + organizationId, + auditData: { + status: { old: oldStatus, new: BookingStatus.ACCEPTED }, + }, + source: "WEBAPP", + isBookingAuditEnabled, + }); + } catch (error) { + logger.error("Error firing booking accepted audit for instant meeting", error); + } +}