Skip to content

Commit 25857c0

Browse files
yuvrajangadsinghhariombalharadevin-ai-integration[bot]claudebot_apk
authored
feat(bookings): add booking audit logging to instant bookings (calcom#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>
1 parent c7ee77e commit 25857c0

File tree

8 files changed

+557
-18
lines changed

8 files changed

+557
-18
lines changed

apps/web/pages/api/book/instant-event.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ async function handler(req: NextApiRequest & { userId?: number }) {
2525
// 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.
2626
const booking = await instantBookingService.createBooking({
2727
bookingData: req.body,
28+
bookingMeta: {
29+
userUuid: session?.user?.uuid,
30+
impersonatedByUserUuid: null,
31+
},
2832
});
2933

3034
return booking;

packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,11 @@ export class CreatedAuditActionService implements IAuditActionService {
8080

8181
async getDisplayTitle({ storedData, dbStore }: GetDisplayTitleParams): Promise<TranslationWithParams> {
8282
const { fields } = this.parseStored(storedData);
83+
if (fields.status === BookingStatus.AWAITING_HOST) {
84+
return { key: "booking_audit_action.created_awaiting_host", params: {} };
85+
}
8386
const hostUser = fields.hostUserUuid ? dbStore.getUserByUuid(fields.hostUserUuid) : null;
84-
const hostName = hostUser?.name || "Unknown";
87+
const hostName = hostUser?.name ?? "Unknown";
8588
if (fields.seatReferenceUid) {
8689
return { key: "booking_audit_action.created_with_seat", params: { host: hostName } };
8790
}

packages/features/bookings/di/InstantBookingCreateService.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { InstantBookingCreateService } from "@calcom/features/bookings/lib/service/InstantBookingCreateService";
2+
import { moduleLoader as bookingEventHandlerModuleLoader } from "@calcom/features/bookings/di/BookingEventHandlerService.module";
23
import { createModule, bindModuleToClassOnToken } from "@calcom/features/di/di";
4+
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
35
import { DI_TOKENS } from "@calcom/features/di/tokens";
46
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
57

@@ -14,6 +16,8 @@ const loadModule = bindModuleToClassOnToken({
1416
depsMap: {
1517
// TODO: In a followup PR, we aim to remove prisma dependency and instead inject the repositories as dependencies.
1618
prismaClient: prismaModuleLoader,
19+
bookingEventHandler: bookingEventHandlerModuleLoader,
20+
featuresRepository: featuresRepositoryModuleLoader,
1721
},
1822
});
1923

packages/features/bookings/lib/service/InstantBookingCreateService.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,168 @@ describe("handleInstantMeeting", () => {
178178
})
179179
).rejects.toThrow("Only Team Event Types are supported for Instant Meeting");
180180
});
181+
182+
it("should fire booking audit event with correct data when org has booking-audit feature", async () => {
183+
const { BookingEventHandlerService } = await import("../onBookingEvents/BookingEventHandlerService");
184+
const onBookingCreatedSpy = vi
185+
.spyOn(BookingEventHandlerService.prototype, "onBookingCreated")
186+
.mockResolvedValue(undefined);
187+
188+
const instantBookingCreateService = getInstantBookingCreateService();
189+
const organizer = getOrganizer({
190+
name: "Organizer",
191+
email: "organizer@example.com",
192+
id: 101,
193+
schedules: [TestData.schedules.IstWorkHours],
194+
credentials: [getGoogleCalendarCredential()],
195+
selectedCalendars: [TestData.selectedCalendars.google],
196+
});
197+
198+
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
199+
200+
await createBookingScenario(
201+
getScenarioData({
202+
eventTypes: [
203+
{
204+
id: 1,
205+
slotInterval: 45,
206+
length: 45,
207+
users: [{ id: 101 }],
208+
team: { id: 1 },
209+
instantMeetingExpiryTimeOffsetInSeconds: 90,
210+
},
211+
],
212+
organizer,
213+
apps: [TestData.apps["daily-video"], TestData.apps["google-calendar"]],
214+
})
215+
);
216+
217+
mockSuccessfulVideoMeetingCreation({
218+
metadataLookupKey: "dailyvideo",
219+
videoMeetingData: {
220+
id: "MOCK_ID",
221+
password: "MOCK_PASS",
222+
url: `http://mock-dailyvideo.example.com/meeting-1`,
223+
},
224+
});
225+
mockCalendarToHaveNoBusySlots("googlecalendar", {
226+
create: { uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID" },
227+
});
228+
229+
const mockBookingData: CreateInstantBookingData = {
230+
eventTypeId: 1,
231+
timeZone: "UTC",
232+
language: "en",
233+
start: `${plus1DateString}T04:00:00.000Z`,
234+
end: `${plus1DateString}T04:45:00.000Z`,
235+
responses: {
236+
name: "Test User",
237+
email: "test@example.com",
238+
attendeePhoneNumber: "+918888888888",
239+
},
240+
metadata: {},
241+
instant: true,
242+
};
243+
244+
const result = await instantBookingCreateService.createBooking({
245+
bookingData: mockBookingData,
246+
});
247+
248+
expect(result.message).toBe("Success");
249+
expect(result.bookingId).toBeDefined();
250+
251+
expect(onBookingCreatedSpy).toHaveBeenCalledTimes(1);
252+
253+
const callArgs = onBookingCreatedSpy.mock.calls[0][0];
254+
expect(callArgs.payload.booking.uid).toBe(result.bookingUid);
255+
expect(callArgs.payload.config.isDryRun).toBe(false);
256+
expect(callArgs.actor).toEqual(
257+
expect.objectContaining({ identifiedBy: expect.any(String) })
258+
);
259+
expect(callArgs.auditData).toEqual(
260+
expect.objectContaining({
261+
startTime: expect.any(Number),
262+
endTime: expect.any(Number),
263+
status: expect.any(String),
264+
})
265+
);
266+
expect(callArgs.source).toEqual(expect.any(String));
267+
268+
onBookingCreatedSpy.mockRestore();
269+
});
270+
271+
it("should not throw when booking audit event fails", async () => {
272+
const { BookingEventHandlerService } = await import("../onBookingEvents/BookingEventHandlerService");
273+
const onBookingCreatedSpy = vi
274+
.spyOn(BookingEventHandlerService.prototype, "onBookingCreated")
275+
.mockRejectedValue(new Error("Audit event handler failure"));
276+
277+
const instantBookingCreateService = getInstantBookingCreateService();
278+
const organizer = getOrganizer({
279+
name: "Organizer",
280+
email: "organizer@example.com",
281+
id: 101,
282+
schedules: [TestData.schedules.IstWorkHours],
283+
credentials: [getGoogleCalendarCredential()],
284+
selectedCalendars: [TestData.selectedCalendars.google],
285+
});
286+
287+
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
288+
289+
await createBookingScenario(
290+
getScenarioData({
291+
eventTypes: [
292+
{
293+
id: 1,
294+
slotInterval: 45,
295+
length: 45,
296+
users: [{ id: 101 }],
297+
team: { id: 1 },
298+
instantMeetingExpiryTimeOffsetInSeconds: 90,
299+
},
300+
],
301+
organizer,
302+
apps: [TestData.apps["daily-video"], TestData.apps["google-calendar"]],
303+
})
304+
);
305+
306+
mockSuccessfulVideoMeetingCreation({
307+
metadataLookupKey: "dailyvideo",
308+
videoMeetingData: {
309+
id: "MOCK_ID",
310+
password: "MOCK_PASS",
311+
url: `http://mock-dailyvideo.example.com/meeting-1`,
312+
},
313+
});
314+
mockCalendarToHaveNoBusySlots("googlecalendar", {
315+
create: { uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID" },
316+
});
317+
318+
const mockBookingData: CreateInstantBookingData = {
319+
eventTypeId: 1,
320+
timeZone: "UTC",
321+
language: "en",
322+
start: `${plus1DateString}T04:00:00.000Z`,
323+
end: `${plus1DateString}T04:45:00.000Z`,
324+
responses: {
325+
name: "Test User",
326+
email: "test@example.com",
327+
attendeePhoneNumber: "+918888888888",
328+
},
329+
metadata: {},
330+
instant: true,
331+
};
332+
333+
const result = await instantBookingCreateService.createBooking({
334+
bookingData: mockBookingData,
335+
});
336+
337+
expect(result.message).toBe("Success");
338+
expect(result.bookingId).toBeDefined();
339+
expect(onBookingCreatedSpy).toHaveBeenCalled();
340+
341+
onBookingCreatedSpy.mockRestore();
342+
});
181343
});
182344
});
183345

0 commit comments

Comments
 (0)