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
This commit is contained in:
@@ -579,5 +579,6 @@ function getTrpcHandlerData({
|
||||
} as unknown as NonNullable<TrpcSessionUser>,
|
||||
},
|
||||
input: input,
|
||||
source: "WEBAPP" as const,
|
||||
};
|
||||
}
|
||||
|
||||
+57
-56
@@ -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<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.reschedule_requested" };
|
||||
}
|
||||
|
||||
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
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<typeof fieldsSchemaV1>;
|
||||
|
||||
export type RescheduleRequestedAuditDisplayData = {
|
||||
reason: string | null;
|
||||
requestedBy: string | null;
|
||||
reason: string | null;
|
||||
requestedBy: string | null;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user