feat(bookings): add booking audit logging to instant bookings (#28176)

* feat(bookings): add booking audit logging to instant bookings

wire up BookingEventHandlerService.onBookingCreated in
InstantBookingCreateService to emit audit events, matching the
pattern already used in RegularBookingService and
RecurringBookingService.

* refactor: extract fireBookingEvents and reuse existing orgId

* refactor: derive orgId once and pass to both webhook trigger and audit event

* fix: add missing return in webhook map callback

* refactor: make creationSource required for instant bookings

Both callers (WEBAPP and API_V2) always set creationSource, so validate
it upfront and use CreationSource enum type instead of string | null.

* fix: use ErrorWithCode instead of Error, pass userUuid to audit events

* fix: pass null for hostUserUuid in instant booking audit data

Instant bookings have status AWAITING_HOST with no assigned host,
so the booker's UUID should not be recorded as hostUserUuid.

* fix: address devin review - hostUserUuid and creationSource validation

* fix: address review - bookingMeta, getOrgIdFromMemberOrTeamId, required creationSource

- pass userUuid via bookingMeta instead of separate param (matches RegularBookingService pattern)
- restore getOrgIdFromMemberOrTeamId for proper org resolution instead of eventType.team.parentId
- make creationSource required with runtime validation instead of defaulting to WEBAPP

* fix: enforce creationSource at compile time instead of runtime

use Required<Pick<>> to make creationSource required in the type
signature. removes the runtime check since TypeScript catches
missing creationSource at build time.

* fix: simplify type signature and derive hostUserUuid from booking relation

- Replace Required<Pick<CreateInstantBookingData, 'creationSource'>> with inline { creationSource: CreationSource }
- Include user relation in booking create query to derive hostUserUuid
- Pass newBooking.user?.uuid instead of hardcoding null for userUuid in audit data

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* chore: trigger CI

* fix: add missing impersonatedByUserUuid to instant booking meta

The CreateBookingMeta type requires impersonatedByUserUuid. Set it to
null for non-impersonated instant bookings.

* fix: show 'awaiting host' in audit log for instant bookings

Use booking status AWAITING_HOST to display "Booked (awaiting host)"
instead of "Booked with Unknown" when no host has accepted yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add booking audit for instant meeting accept via connect-and-join

Extract fireInstantBookingAcceptedAuditEvent to InstantBookingCreateService
and fire it right after the DB update, before side-effect notifications.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add audit logging tests for instant booking creation

* fix: simulate audit failure in resilience test (identified by cubic)

The test 'should not throw when booking audit event fails' was not
actually simulating an audit failure. Added vi.spyOn on
BookingEventHandlerService.prototype.onBookingCreated to reject with
an error, and assert the spy was called, proving the try/catch in
fireBookingEvents properly catches the error without breaking the
booking flow.

Co-Authored-By: bot_apk <apk@cognition.ai>

* test: add audit event tests for connectAndJoin and fix InstantBooking audit test

* test

---------

Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bot_apk <apk@cognition.ai>
This commit is contained in:
Yuvraj Angad Singh
2026-04-04 13:30:03 -03:00
committed by GitHub
co-authored by bot_apk Hariom Balhara Devin AI hariom@cal.com Claude Opus 4.6
parent c7ee77ee20
commit 25857c07e1
8 changed files with 557 additions and 18 deletions
+4
View File
@@ -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;
@@ -80,8 +80,11 @@ export class CreatedAuditActionService implements IAuditActionService {
async getDisplayTitle({ storedData, dbStore }: GetDisplayTitleParams): Promise<TranslationWithParams> {
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 } };
}
@@ -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,
},
});
@@ -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();
});
});
});
@@ -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<string, unknown>;
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<InstantBookingCreateResult> {
return handler(input.bookingData, this.deps);
async createBooking(input: {
bookingData: CreateInstantBookingData & { creationSource: CreationSource };
bookingMeta?: CreateBookingMeta;
}): Promise<InstantBookingCreateResult> {
return handler(input.bookingData, this.deps, input.bookingMeta);
}
}
+1
View File
@@ -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}}</0>",
@@ -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<Record<string, unknown>> = {}) {
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<TrpcSessionUser>;
}
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();
});
});
});
@@ -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);
}
}