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:
co-authored by
bot_apk
Hariom Balhara
Devin AI
hariom@cal.com
Claude Opus 4.6
parent
c7ee77ee20
commit
25857c07e1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user