From f3a37886e37d1c37c72dd31566ba23736185d83a Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Fri, 9 Jan 2026 14:03:01 +0530 Subject: [PATCH] chore: Integrate reschedule request booking audit (#26527) ## What does this PR do? Adds audit logging for the reschedule request flow. When an organizer requests an attendee to reschedule a booking, this action is now logged to the booking audit system. This is part of the booking audit integration effort, following the pattern established in PR #26046 (booking creation/rescheduling audit) and PR #26458 (cancellation audit). ## Changes - Added audit logging call to `requestReschedule.handler.ts` using `BookingEventHandlerService.onRescheduleRequested()` - Captures audit data: - `rescheduleReason`: The reason provided for requesting the reschedule (nullable string) - `rescheduledRequestedBy`: Email of the user who requested the reschedule - Uses `makeUserActor(user.uuid)` to identify the actor performing the action - Added required `source` parameter for action source tracking (passed as `"WEBAPP"` from tRPC router) - Updated `RescheduleRequestedAuditActionService` schema to use simpler field structure - Updated test helper `getTrpcHandlerData` to pass `source: "WEBAPP"` parameter ## Updates since last revision - Changed `source` parameter from optional with default to required - tests now explicitly pass `source: "WEBAPP"` instead of relying on a default value ## 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. - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. N/A - follows established audit pattern with existing infrastructure tests. ## How should this be tested? 1. Log in as a user with a booking 2. Navigate to the booking and click "Request Reschedule" 3. Provide a reschedule reason and submit 4. Verify the audit log is created with the correct data (requires access to audit log storage/database) The audit logging follows the same pattern as other booking audit integrations, so if those are working, this should work as well. ## Checklist - [x] My code follows the style guidelines of this project - [x] I have checked if my changes generate no new warnings --- **Link to Devin run**: https://app.devin.ai/sessions/ae84d093c4594a5d89b88c45639a6a06 **Requested by**: @hariombalhara ## Human Review Checklist - [ ] Verify the audit data schema matches `RescheduleRequestedAuditActionService` expectations (fields: `rescheduleReason`, `rescheduledRequestedBy`) - [ ] Confirm the schema change from change-based fields (`{ old, new }`) to simple nullable strings is intentional - [ ] Confirm `user.uuid` is available in the tRPC session context - [ ] Note: API v2 does not have a reschedule request endpoint, so no API v2 changes are needed --- .../test/handlers/requestReschedule.test.ts | 1 + .../RescheduleRequestedAuditActionService.ts | 113 +++++++++--------- .../routers/viewer/bookings/_router.tsx | 3 + .../bookings/requestReschedule.handler.ts | 18 ++- 4 files changed, 78 insertions(+), 57 deletions(-) diff --git a/apps/web/test/handlers/requestReschedule.test.ts b/apps/web/test/handlers/requestReschedule.test.ts index 5e3f838816..9b4ca962db 100644 --- a/apps/web/test/handlers/requestReschedule.test.ts +++ b/apps/web/test/handlers/requestReschedule.test.ts @@ -579,5 +579,6 @@ function getTrpcHandlerData({ } as unknown as NonNullable, }, input: input, + source: "WEBAPP" as const, }; } diff --git a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts index a98b55e636..0da44589f8 100644 --- a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts @@ -1,8 +1,12 @@ import { z } from "zod"; -import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; +import type { + IAuditActionService, + TranslationWithParams, + GetDisplayTitleParams, + GetDisplayJsonParams, +} from "./IAuditActionService"; /** * Reschedule Requested Audit Action Service @@ -11,73 +15,70 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, // Module-level because it is passed to IAuditActionService type outside the class scope const fieldsSchemaV1 = z.object({ - cancellationReason: StringChangeSchema, - cancelledBy: StringChangeSchema, - rescheduled: BooleanChangeSchema.optional(), + rescheduleReason: z.string().nullable(), + rescheduledRequestedBy: z.string().nullable(), }); export class RescheduleRequestedAuditActionService implements IAuditActionService { - readonly VERSION = 1; - public static readonly TYPE = "RESCHEDULE_REQUESTED" as const; - private static dataSchemaV1 = z.object({ - version: z.literal(1), - fields: fieldsSchemaV1, + readonly VERSION = 1; + public static readonly TYPE = "RESCHEDULE_REQUESTED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = RescheduleRequestedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = RescheduleRequestedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof RescheduleRequestedAuditActionService.latestFieldsSchema, + typeof RescheduleRequestedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: RescheduleRequestedAuditActionService.latestFieldsSchema, + storedDataSchema: RescheduleRequestedAuditActionService.storedDataSchema, }); - private static fieldsSchemaV1 = fieldsSchemaV1; - public static readonly latestFieldsSchema = fieldsSchemaV1; - // Union of all versions - public static readonly storedDataSchema = RescheduleRequestedAuditActionService.dataSchemaV1; - // Union of all versions - public static readonly storedFieldsSchema = RescheduleRequestedAuditActionService.fieldsSchemaV1; - private helper: AuditActionServiceHelper< - typeof RescheduleRequestedAuditActionService.latestFieldsSchema, - typeof RescheduleRequestedAuditActionService.storedDataSchema - >; + } - constructor() { - this.helper = new AuditActionServiceHelper({ - latestVersion: this.VERSION, - latestFieldsSchema: RescheduleRequestedAuditActionService.latestFieldsSchema, - storedDataSchema: RescheduleRequestedAuditActionService.storedDataSchema, - }); - } + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } - getVersionedData(fields: unknown) { - return this.helper.getVersionedData(fields); - } + parseStored(data: unknown) { + return this.helper.parseStored(data); + } - parseStored(data: unknown) { - return this.helper.parseStored(data); - } + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } - getVersion(data: unknown): number { - return this.helper.getVersion(data); - } + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } - migrateToLatest(data: unknown) { - // V1-only: validate and return as-is (no migration needed) - const validated = fieldsSchemaV1.parse(data); - return { isMigrated: false, latestData: validated }; - } + async getDisplayTitle(_: GetDisplayTitleParams): Promise { + return { key: "booking_audit_action.reschedule_requested" }; + } - async getDisplayTitle(_: GetDisplayTitleParams): Promise { - return { key: "booking_audit_action.reschedule_requested" }; - } - - getDisplayJson({ - storedData, - }: GetDisplayJsonParams): RescheduleRequestedAuditDisplayData { - const { fields } = this.parseStored(storedData); - return { - reason: fields.cancellationReason.new ?? null, - requestedBy: fields.cancelledBy.new ?? null, - }; - } + getDisplayJson({ storedData }: GetDisplayJsonParams): RescheduleRequestedAuditDisplayData { + const { fields } = this.parseStored(storedData); + return { + reason: fields.rescheduleReason ?? null, + requestedBy: fields.rescheduledRequestedBy ?? null, + }; + } } export type RescheduleRequestedAuditData = z.infer; export type RescheduleRequestedAuditDisplayData = { - reason: string | null; - requestedBy: string | null; + reason: string | null; + requestedBy: string | null; }; diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 2b456aceb0..6d7f5fee85 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -1,3 +1,5 @@ +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; + import authedProcedure from "../../../procedures/authedProcedure"; import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; @@ -30,6 +32,7 @@ export const bookingsRouter = router({ return requestRescheduleHandler({ ctx, input, + source: "WEBAPP" }); }), diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 27102492c4..7ee5bd3fbd 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -5,6 +5,9 @@ import { getDelegationCredentialOrRegularCredential } from "@calcom/app-store/de import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential"; import dayjs from "@calcom/dayjs"; import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails/email-manager"; +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { deleteMeeting } from "@calcom/features/conferencing/lib/videoClient"; @@ -43,9 +46,10 @@ type RequestRescheduleOptions = { user: NonNullable; }; input: TRequestRescheduleInputSchema; + source: ActionSource; }; const log = logger.getSubLogger({ prefix: ["requestRescheduleHandler"] }); -export const requestRescheduleHandler = async ({ ctx, input }: RequestRescheduleOptions) => { +export const requestRescheduleHandler = async ({ ctx, input, source }: RequestRescheduleOptions) => { const { user } = ctx; const { bookingUid, rescheduleReason: cancellationReason } = input; log.debug("Started", safeStringify({ bookingUid })); @@ -305,4 +309,16 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule }) ); await Promise.all(promises); + + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onRescheduleRequested({ + bookingUid: bookingToReschedule.uid, + actor: makeUserActor(user.uuid), + organizationId: orgId ?? null, + source, + auditData: { + rescheduleReason: cancellationReason ?? null, + rescheduledRequestedBy: user.email, + }, + }); };