Skip to content

Commit ca32f04

Browse files
chore: Integrate booking cancellation audit (calcom#26458)
## What does this PR do? > **⚠️ Note: This PR does not enable booking audit in production.** The `BookingAuditTaskerProducerService` has an [`IS_PRODUCTION` guard](https://github.com/calcom/cal.com/blob/integrate-booking-creation-reschedule-audit/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts#L106-L108) that skips audit task queueing in production environments. This allows the integration to be tested in development before enabling it in production. Integrates audit logging for booking cancellations, following the pattern established in PR calcom#26046 for booking creation/rescheduling audit. - Related to calcom#25125 (Booking Audit Infrastructure) ### Changes: - Add audit logging for single booking cancellation via `onBookingCancelled` - Add audit logging for bulk recurring booking cancellation via `onBulkBookingsCancelled` - Pass `userUuid` and `actionSource` from webapp cancel route (WEBAPP) - Pass `userUuid` and `actionSource` from API-v2 bookings service (API_V2) - Create `getAuditActor` helper to derive actor from userUuid or create synthetic guest actor - Add `getUniqueIdentifier` helper for generating unique actor identifiers - Add warning log when `actionSource` is "UNKNOWN" for observability - Add integration tests for booking cancellation audit ### Audit Data Captured: - `cancellationReason` (simple string value) - `cancelledBy` (simple string value) - `status` (old → new, e.g., "ACCEPTED" → "CANCELLED") ### Updates since last revision: - Simplified `CancelledAuditActionService` schema: `cancellationReason` and `cancelledBy` are now stored as simple nullable strings instead of change objects (old/new), since cancellation is a one-time event where tracking previous values doesn't apply - Added integration tests for cancelled booking audit in `booking-audit-cancelled.integration-test.ts` - Added `getUniqueIdentifier` helper function in actor.ts for generating unique identifiers with prefixes ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - no documentation changes needed. - [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Cancel a single booking via the webapp - verify audit record is created with actor and actionSource="WEBAPP" 2. Cancel a single booking via API v2 - verify audit record is created with actionSource="API_V2" 3. Cancel all remaining bookings in a recurring series - verify bulk audit records are created with shared operationId 4. Cancel via unauthenticated cancel link - verify guest actor is created with synthetic email (prefixed with "param-" or "fallback-") 5. Run integration tests: `yarn test packages/features/booking-audit/lib/service/__tests__/booking-audit-cancelled.integration-test.ts` ## Human Review Checklist - [ ] Verify `onBookingCancelled` and `onBulkBookingsCancelled` methods exist in `BookingEventHandlerService` - [ ] Review the `getAuditActor` fallback logic - creates synthetic email with "fallback-" or "param-" prefix when no userUuid available - [ ] Confirm the simplified schema for `cancellationReason`/`cancelledBy` (no longer tracking old→new) is intentional - [ ] Note: Audit logging calls are awaited directly - if audit service fails, the cancellation will fail. Confirm this is the desired behavior. - [ ] Verify `CancelledAuditDisplayData` type no longer includes `previousReason` and `previousCancelledBy` fields --- Link to Devin run: https://app.devin.ai/sessions/42404e76a66946fe9e46fa07fb12e779 Requested by: @hariombalhara (hariom@cal.com)
1 parent 9e84f59 commit ca32f04

7 files changed

Lines changed: 291 additions & 38 deletions

File tree

apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,8 @@ export class BookingsService_2024_08_13 {
889889
const res = await handleCancelBooking({
890890
bookingData: bookingRequest.body,
891891
userId: bookingRequest.userId,
892+
userUuid: authUser?.uuid,
893+
actionSource: "API_V2",
892894
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
893895
platformClientId: bookingRequest.platformClientId,
894896
platformCancelUrl: bookingRequest.platformCancelUrl,

apps/web/app/api/cancel/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ async function handler(req: NextRequest) {
4141
const result = await handleCancelBooking({
4242
bookingData,
4343
userId: session?.user?.id || -1,
44+
userUuid: session?.user?.uuid,
45+
actionSource: "WEBAPP",
4446
});
4547

4648
// const bookingCancelService = getBookingCancelService();

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

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from "zod";
22

3-
import { StringChangeSchema } from "../common/changeSchemas";
3+
import { StringChangeSchema, BookingStatusChangeSchema } from "../common/changeSchemas";
44
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
55
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
66

@@ -11,9 +11,9 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams,
1111

1212
// Module-level because it is passed to IAuditActionService type outside the class scope
1313
const fieldsSchemaV1 = z.object({
14-
cancellationReason: StringChangeSchema,
15-
cancelledBy: StringChangeSchema,
16-
status: StringChangeSchema,
14+
cancellationReason: z.string().nullable(),
15+
cancelledBy: z.string().nullable(),
16+
status: BookingStatusChangeSchema,
1717
});
1818

1919
export class CancelledAuditActionService implements IAuditActionService {
@@ -69,10 +69,8 @@ export class CancelledAuditActionService implements IAuditActionService {
6969
}: GetDisplayJsonParams): CancelledAuditDisplayData {
7070
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
7171
return {
72-
cancellationReason: fields.cancellationReason.new ?? null,
73-
previousReason: fields.cancellationReason.old ?? null,
74-
cancelledBy: fields.cancelledBy.new ?? null,
75-
previousCancelledBy: fields.cancelledBy.old ?? null,
72+
cancellationReason: fields.cancellationReason ?? null,
73+
cancelledBy: fields.cancelledBy ?? null,
7674
previousStatus: fields.status.old ?? null,
7775
newStatus: fields.status.new ?? null,
7876
};
@@ -83,9 +81,7 @@ export type CancelledAuditData = z.infer<typeof fieldsSchemaV1>;
8381

8482
export type CancelledAuditDisplayData = {
8583
cancellationReason: string | null;
86-
previousReason: string | null;
8784
cancelledBy: string | null;
88-
previousCancelledBy: string | null;
8985
previousStatus: string | null;
9086
newStatus: string | null;
9187
};

packages/features/booking-audit/lib/makeActor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { UserActor, GuestActor, AttendeeActor, ActorById, AppActorByCredentialId, AppActorBySlug } from "./dto/types";
2+
import { v4 as uuidv4 } from "uuid";
23

34
const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
45

@@ -77,7 +78,13 @@ export function makeAppActorUsingSlug(params: { appSlug: string; name: string })
7778
};
7879
}
7980

81+
/**
82+
* identifier should be unique for that actor
83+
*/
8084
export function buildActorEmail({ identifier, actorType }: { identifier: string, actorType: "system" | "guest" | "app" }): string {
8185
return `${identifier}@${actorType}.internal`;
8286
}
8387

88+
export function getUniqueIdentifier({ prefix }: { prefix: string }): string {
89+
return `${prefix}-${uuidv4()}`;
90+
}

packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.s
1010

1111
import { BookingAuditViewerService } from "../BookingAuditViewerService";
1212
import { BookingAuditPermissionError, BookingAuditErrorCode } from "../BookingAuditAccessService";
13-
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../../repository/IBookingAuditRepository";
13+
import type {
14+
IBookingAuditRepository,
15+
BookingAuditWithActor,
16+
BookingAuditAction,
17+
BookingAuditType,
18+
} from "../../repository/IBookingAuditRepository";
1419
import type { AuditActorType } from "../../repository/IAuditActorRepository";
1520
import type { BookingAuditContext } from "../../dto/types";
1621

@@ -31,15 +36,14 @@ const createMockTeamBooking = (overrides?: {
3136
email: "test@example.com",
3237
},
3338
eventType: {
34-
teamId: (overrides && "teamId" in overrides ? overrides.teamId : overrides?.teamId ?? 100) ?? null,
39+
teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null,
3540
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
3641
hosts: [],
3742
users: [],
3843
},
3944
attendees: [],
4045
});
4146

42-
4347
const createMockAuditLog = (
4448
overrides?: Partial<{
4549
id: string;
@@ -65,22 +69,29 @@ const createMockAuditLog = (
6569
createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"),
6670
updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"),
6771
actorId: overrides?.actorId ?? "actor-1",
68-
data: overrides?.data ?? { version: 1, fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" } },
72+
data: overrides?.data ?? {
73+
version: 1,
74+
fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" },
75+
},
6976
source: "WEBAPP" as const,
7077
operationId: "operation-id-123",
7178
context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null,
7279
actor: {
7380
id: overrides?.actorId ?? "actor-1",
74-
type: overrides?.actorType ?? "USER" as const,
75-
userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as string | null,
81+
type: overrides?.actorType ?? ("USER" as const),
82+
userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as
83+
| string
84+
| null,
7685
attendeeId: null,
7786
credentialId: null,
7887
name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null,
7988
createdAt: new Date("2024-01-01T00:00:00Z"),
8089
},
8190
});
8291

83-
const createMockUser = (overrides?: Partial<{ id: number; name: string | null; email: string; avatarUrl: string | null }>) => ({
92+
const createMockUser = (
93+
overrides?: Partial<{ id: number; name: string | null; email: string; avatarUrl: string | null }>
94+
) => ({
8495
id: overrides?.id ?? 123,
8596
name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null,
8697
email: overrides?.email ?? "john@example.com",
@@ -157,11 +168,21 @@ describe("BookingAuditViewerService - Integration Tests", () => {
157168
error: vi.fn(),
158169
};
159170

160-
vi.mocked(BookingRepository).mockImplementation(function () { return mockBookingRepository as unknown as BookingRepository; });
161-
vi.mocked(UserRepository).mockImplementation(function () { return mockUserRepository as unknown as UserRepository; });
162-
vi.mocked(MembershipRepository).mockImplementation(function () { return mockMembershipRepository as unknown as MembershipRepository; });
163-
vi.mocked(PermissionCheckService).mockImplementation(function () { return mockPermissionCheckService as unknown as PermissionCheckService; });
164-
vi.mocked(CredentialRepository).mockImplementation(function () { return mockCredentialRepository as unknown as CredentialRepository; });
171+
vi.mocked(BookingRepository).mockImplementation(function () {
172+
return mockBookingRepository as unknown as BookingRepository;
173+
});
174+
vi.mocked(UserRepository).mockImplementation(function () {
175+
return mockUserRepository as unknown as UserRepository;
176+
});
177+
vi.mocked(MembershipRepository).mockImplementation(function () {
178+
return mockMembershipRepository as unknown as MembershipRepository;
179+
});
180+
vi.mocked(PermissionCheckService).mockImplementation(function () {
181+
return mockPermissionCheckService as unknown as PermissionCheckService;
182+
});
183+
vi.mocked(CredentialRepository).mockImplementation(function () {
184+
return mockCredentialRepository as unknown as CredentialRepository;
185+
});
165186

166187
service = new BookingAuditViewerService({
167188
bookingAuditRepository: mockBookingAuditRepository as unknown as IBookingAuditRepository,
@@ -260,13 +281,16 @@ describe("BookingAuditViewerService - Integration Tests", () => {
260281
id: "log-1",
261282
action: "CREATED",
262283
timestamp: new Date("2024-01-15T10:00:00Z"),
263-
data: { version: 1, fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" } }
284+
data: {
285+
version: 1,
286+
fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" },
287+
},
264288
}),
265289
createMockAuditLog({
266290
id: "log-2",
267291
action: "ACCEPTED",
268292
timestamp: new Date("2024-01-15T11:00:00Z"),
269-
data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } }
293+
data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } },
270294
}),
271295
createMockAuditLog({
272296
id: "log-3",
@@ -276,10 +300,10 @@ describe("BookingAuditViewerService - Integration Tests", () => {
276300
version: 1,
277301
fields: {
278302
status: { old: "ACCEPTED", new: "CANCELLED" },
279-
cancellationReason: { old: null, new: "User requested" },
280-
cancelledBy: { old: null, new: "user-123" },
281-
}
282-
}
303+
cancellationReason: "User requested",
304+
cancelledBy: "user-123",
305+
},
306+
},
283307
}),
284308
];
285309

@@ -357,7 +381,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
357381
it("should use email as display name when user name is null", async () => {
358382
const mockLog = createMockAuditLog({
359383
actorType: "USER",
360-
actorName: null
384+
actorName: null,
361385
});
362386
const mockUser = createMockUser({ name: null, email: "user@example.com" });
363387

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, it, beforeEach, afterEach } from "vitest";
2+
3+
import { BookingStatus } from "@calcom/prisma/enums";
4+
5+
import type { BookingAuditTaskConsumer } from "../BookingAuditTaskConsumer";
6+
import type { BookingAuditViewerService } from "../BookingAuditViewerService";
7+
import { makeUserActor } from "../../makeActor";
8+
import { getBookingAuditTaskConsumer } from "../../../di/BookingAuditTaskConsumer.container";
9+
import { getBookingAuditViewerService } from "../../../di/BookingAuditViewerService.container";
10+
import {
11+
createTestUser,
12+
createTestOrganization,
13+
createTestMembership,
14+
createTestEventType,
15+
createTestBooking,
16+
enableFeatureForOrganization,
17+
cleanupTestData,
18+
} from "./integration-utils";
19+
20+
describe("Cancelled Action Integration", () => {
21+
let bookingAuditTaskConsumer: BookingAuditTaskConsumer;
22+
let bookingAuditViewerService: BookingAuditViewerService;
23+
24+
let testData: {
25+
owner: { id: number; uuid: string; email: string };
26+
attendee: { id: number; email: string };
27+
organization: { id: number };
28+
eventType: { id: number };
29+
booking: { uid: string; startTime: Date; endTime: Date; status: BookingStatus };
30+
};
31+
32+
beforeEach(async () => {
33+
bookingAuditTaskConsumer = getBookingAuditTaskConsumer();
34+
bookingAuditViewerService = getBookingAuditViewerService();
35+
36+
const owner = await createTestUser({ name: "Test Audit User" });
37+
const organization = await createTestOrganization();
38+
await createTestMembership(owner.id, organization.id);
39+
await enableFeatureForOrganization(organization.id, "booking-audit");
40+
const eventType = await createTestEventType(owner.id);
41+
const attendee = await createTestUser({ name: "Test Attendee" });
42+
43+
const booking = await createTestBooking(owner.id, eventType.id, {
44+
attendees: [
45+
{
46+
email: attendee.email,
47+
name: attendee.name || "Test Attendee",
48+
timeZone: "UTC",
49+
},
50+
],
51+
});
52+
53+
testData = {
54+
owner: { id: owner.id, uuid: owner.uuid, email: owner.email },
55+
attendee: { id: attendee.id, email: attendee.email },
56+
organization: { id: organization.id },
57+
eventType: { id: eventType.id },
58+
booking: {
59+
uid: booking.uid,
60+
startTime: booking.startTime,
61+
endTime: booking.endTime,
62+
status: booking.status,
63+
},
64+
};
65+
});
66+
67+
afterEach(async () => {
68+
if (!testData) return;
69+
70+
await cleanupTestData({
71+
bookingUid: testData.booking?.uid,
72+
userUuids: testData.owner?.uuid ? [testData.owner.uuid] : [],
73+
attendeeEmails: testData.attendee?.email ? [testData.attendee.email] : [],
74+
eventTypeId: testData.eventType?.id,
75+
organizationId: testData.organization?.id,
76+
userIds: [testData.owner?.id, testData.attendee?.id].filter((id): id is number => id !== undefined),
77+
featureSlug: "booking-audit",
78+
});
79+
});
80+
81+
describe("when booking is cancelled", () => {
82+
it("should create audit record with cancellation details and retrieve it correctly", async () => {
83+
const actor = makeUserActor(testData.owner.uuid);
84+
const cancellationReason = "Schedule conflict";
85+
const cancelledBy = "owner";
86+
87+
await bookingAuditTaskConsumer.onBookingAction({
88+
bookingUid: testData.booking.uid,
89+
actor,
90+
action: "CANCELLED",
91+
source: "WEBAPP",
92+
operationId: `op-${Date.now()}`,
93+
data: {
94+
cancellationReason,
95+
cancelledBy,
96+
status: {
97+
old: BookingStatus.ACCEPTED,
98+
new: BookingStatus.CANCELLED,
99+
},
100+
},
101+
timestamp: Date.now(),
102+
});
103+
104+
const result = await bookingAuditViewerService.getAuditLogsForBooking({
105+
bookingUid: testData.booking.uid,
106+
userId: testData.owner.id,
107+
userEmail: testData.owner.email,
108+
userTimeZone: "UTC",
109+
organizationId: testData.organization.id,
110+
});
111+
112+
expect(result.auditLogs).toHaveLength(1);
113+
const auditLog = result.auditLogs[0];
114+
115+
expect(auditLog.action).toBe("CANCELLED");
116+
expect(auditLog.type).toBe("RECORD_UPDATED");
117+
118+
const displayData = auditLog.displayJson as Record<string, unknown>;
119+
expect(displayData.cancellationReason).toBe(cancellationReason);
120+
expect(displayData.cancelledBy).toBe(cancelledBy);
121+
expect(displayData.previousStatus).toBe(BookingStatus.ACCEPTED);
122+
expect(displayData.newStatus).toBe(BookingStatus.CANCELLED);
123+
});
124+
});
125+
});
126+

0 commit comments

Comments
 (0)