Files
cal.diy/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts
T
Hariom Balhara a056c3217e chore: Add impersonation context support to booking audit (#26014)
## What does this PR do?

Adds infrastructure to track impersonation context in booking audit records and displays it in the UI. When an admin impersonates another user and performs booking actions, the audit system now:
- Records the **admin** as the actor (who actually performed the action)
- Stores the **impersonated user's UUID** in a separate `context` field
- Displays **"Impersonated By"** in the booking logs UI when viewing audit details

This separation ensures audit trail integrity (the admin is accountable) while preserving full context about whose account was being used.

### Changes
- Added `uuid` to `impersonatedBy` session object for actor identification
- Added `uuid` to top-level `User` type in next-auth types for session enrichment
- Added `context Json?` field to `BookingAudit` Prisma model
- Added `BookingAuditContextSchema` for type-safe context handling with `actingAsUserUuid` field
- Updated producer service interface and implementation to pass context through all queue methods
- Updated consumer service to persist context to database
- Updated repository to store and fetch context in BookingAudit records
- Added `impersonatedBy` field to `EnrichedAuditLog` type in `BookingAuditViewerService`
- Added `enrichImpersonationContext` method to resolve impersonated user details
- Updated booking logs UI to display "Impersonated By" in expanded details
- Added `impersonated_by` translation key

Link to Devin run: https://app.devin.ai/sessions/3f1252527aef4ead9401bdf055c0817b
Requested by: hariom@cal.com (@hariombalhara)

## 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 - internal infrastructure change
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

1. Verify type checks pass: `yarn type-check:ci --force`
2. Verify existing audit tests still pass: `TZ=UTC yarn test`
3. To fully test impersonation context display:
   - Have an admin impersonate a user
   - Perform a booking action (create, cancel, reschedule)
   - Navigate to the booking's audit logs
   - Expand the details for the action
   - Verify "Impersonated By" row appears showing the impersonated user's name
   - Verify the BookingAudit record has:
     - `actorId` pointing to the admin's AuditActor
     - `context` containing `{ actingAsUserUuid: "<impersonated-user-uuid>" }`

## Human Review Checklist

- [ ] Verify the `context` field schema design is appropriate for future extensibility
- [ ] Confirm the `uuid` addition to User type in next-auth doesn't break existing auth flows
- [ ] Check that the optional `context` parameter doesn't break existing queue method callers
- [ ] Verify no migration file is needed (or if squashing is handled separately)
- [ ] Verify the UI displays "Impersonated By" correctly when impersonation context is present
- [ ] Confirm `enrichImpersonationContext` handles edge cases (null context, invalid context, deleted user)

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have checked if my changes generate no new warnings
2026-01-06 16:16:18 +05:30

360 lines
13 KiB
TypeScript

import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface";
import type { AcceptedAuditData } from "@calcom/features/booking-audit/lib/actions/AcceptedAuditActionService";
import type { CancelledAuditData } from "@calcom/features/booking-audit/lib/actions/CancelledAuditActionService";
import type { RejectedAuditData } from "@calcom/features/booking-audit/lib/actions/RejectedAuditActionService";
import type { RescheduleRequestedAuditData } from "@calcom/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService";
import type { AttendeeAddedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeAddedAuditActionService";
import type { AttendeeRemovedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService";
import type { ReassignmentAuditData } from "@calcom/features/booking-audit/lib/actions/ReassignmentAuditActionService";
import type { LocationChangedAuditData } from "@calcom/features/booking-audit/lib/actions/LocationChangedAuditActionService";
import type { HostNoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService";
import type { AttendeeNoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService";
import type { SeatBookedAuditData } from "@calcom/features/booking-audit/lib/actions/SeatBookedAuditActionService";
import type { SeatRescheduledAuditData } from "@calcom/features/booking-audit/lib/actions/SeatRescheduledAuditActionService";
import type { CreatedAuditData } from "@calcom/features/booking-audit/lib/actions/CreatedAuditActionService";
import type { RescheduledAuditData } from "@calcom/features/booking-audit/lib/actions/RescheduledAuditActionService";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { Actor, BookingAuditContext } from "@calcom/features/booking-audit/lib/dto/types";
import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types";
interface BookingEventHandlerDeps {
log: ISimpleLogger;
hashedLinkService: HashedLinkService;
bookingAuditProducerService: BookingAuditProducerService;
}
interface OnBookingCreatedParams {
payload: BookingCreatedPayload;
actor: Actor;
auditData: CreatedAuditData;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
}
interface OnBookingRescheduledParams {
payload: BookingRescheduledPayload;
actor: Actor;
auditData: RescheduledAuditData;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
}
interface BaseBookingEventParams<TAuditData> {
bookingUid: string;
actor: Actor;
organizationId: number | null;
auditData: TAuditData;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
}
type OnBookingAcceptedParams = BaseBookingEventParams<AcceptedAuditData>;
type OnBookingCancelledParams = BaseBookingEventParams<CancelledAuditData>;
type OnRescheduleRequestedParams = BaseBookingEventParams<RescheduleRequestedAuditData>;
type OnAttendeeAddedParams = BaseBookingEventParams<AttendeeAddedAuditData>;
type OnHostNoShowUpdatedParams = BaseBookingEventParams<HostNoShowUpdatedAuditData>;
type OnBookingRejectedParams = BaseBookingEventParams<RejectedAuditData>;
type OnAttendeeRemovedParams = BaseBookingEventParams<AttendeeRemovedAuditData>;
type OnReassignmentParams = BaseBookingEventParams<ReassignmentAuditData>;
type OnLocationChangedParams = BaseBookingEventParams<LocationChangedAuditData>;
type OnAttendeeNoShowUpdatedParams = BaseBookingEventParams<AttendeeNoShowUpdatedAuditData>;
type OnSeatBookedParams = BaseBookingEventParams<SeatBookedAuditData>;
type OnSeatRescheduledParams = BaseBookingEventParams<SeatRescheduledAuditData>;
export class BookingEventHandlerService {
private readonly log: BookingEventHandlerDeps["log"];
private readonly bookingAuditProducerService: BookingEventHandlerDeps["bookingAuditProducerService"];
constructor(private readonly deps: BookingEventHandlerDeps) {
this.log = deps.log;
this.bookingAuditProducerService = deps.bookingAuditProducerService;
}
async onBookingCreated(params: OnBookingCreatedParams) {
const { payload, actor, auditData, source, operationId, context } = params;
this.log.debug("onBookingCreated", safeStringify(payload));
if (payload.config.isDryRun) {
return;
}
await this.onBookingCreatedOrRescheduled(payload);
await this.deps.bookingAuditProducerService.queueCreatedAudit({
bookingUid: payload.booking.uid,
actor,
organizationId: payload.organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onBookingRescheduled(params: OnBookingRescheduledParams) {
const { payload, actor, auditData, source, operationId, context } = params;
this.log.debug("onBookingRescheduled", safeStringify(payload));
if (payload.config.isDryRun) {
return;
}
await this.onBookingCreatedOrRescheduled(payload);
await this.bookingAuditProducerService.queueRescheduledAudit({
// In case of rescheduled booking, we send old booking uid because the action took place on that booking only
bookingUid: payload.oldBooking.uid,
actor,
organizationId: payload.organizationId,
source,
operationId,
data: auditData,
context,
});
}
/**
* Handles common tasks that need to be executed in both booking created and rescheduled events
* A dedicated place because there are many tasks that need to be executed in both events.
*/
private async onBookingCreatedOrRescheduled(payload: BookingCreatedPayload | BookingRescheduledPayload) {
const results = await Promise.allSettled([
// TODO: Migrate other post-booking tasks here, to execute them in parallel, without affecting each other
this.updatePrivateLinkUsage(payload.bookingFormData.hashedLink),
]);
results.forEach((result) => {
if (result.status === "rejected") {
this.log.error(
"Error while executing onBookingCreatedOrRescheduled task",
safeStringify(result.reason)
);
}
});
}
private async updatePrivateLinkUsage(hashedLink: string | null) {
try {
if (hashedLink) {
await this.deps.hashedLinkService.validateAndIncrementUsage(hashedLink);
}
} catch (error) {
this.log.error("Error while updating hashed link", safeStringify(error));
}
}
async onBookingAccepted(params: OnBookingAcceptedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueAcceptedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onBookingCancelled(params: OnBookingCancelledParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueCancelledAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onRescheduleRequested(params: OnRescheduleRequestedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueRescheduleRequestedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onAttendeeAdded(params: OnAttendeeAddedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueAttendeeAddedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onHostNoShowUpdated(params: OnHostNoShowUpdatedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueHostNoShowUpdatedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onBookingRejected(params: OnBookingRejectedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueRejectedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onAttendeeRemoved(params: OnAttendeeRemovedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueAttendeeRemovedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onReassignment(params: OnReassignmentParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueReassignmentAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onLocationChanged(params: OnLocationChangedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueLocationChangedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onAttendeeNoShowUpdated(params: OnAttendeeNoShowUpdatedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueAttendeeNoShowUpdatedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onSeatBooked(params: OnSeatBookedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueSeatBookedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
async onSeatRescheduled(params: OnSeatRescheduledParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueSeatRescheduledAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
context,
});
}
/**
* Handles bulk booking acceptance for recurring bookings
* Creates a single task that will be processed to create multiple audit logs atomically
*/
async onBulkBookingsAccepted(params: {
bookings: Array<{
bookingUid: string;
auditData: AcceptedAuditData;
}>;
actor: Actor;
organizationId: number | null;
operationId?: string | null;
source: ActionSource;
context?: BookingAuditContext;
}) {
const { bookings, actor, organizationId, operationId, source, context } = params;
await this.bookingAuditProducerService.queueBulkAcceptedAudit({
bookings: bookings.map((booking) => ({
bookingUid: booking.bookingUid,
data: booking.auditData,
})),
actor,
organizationId,
source,
operationId,
context,
});
}
/**
* Handles bulk booking cancellation for recurring bookings
* Creates a single task that will be processed to create multiple audit logs atomically
*/
async onBulkBookingsCancelled(params: {
bookings: Array<{
bookingUid: string;
auditData: CancelledAuditData;
}>;
actor: Actor;
organizationId: number | null;
operationId?: string | null;
source: ActionSource;
context?: BookingAuditContext;
}) {
const { bookings, actor, organizationId, operationId, source, context } = params;
await this.bookingAuditProducerService.queueBulkCancelledAudit({
bookings: bookings.map((booking) => ({
bookingUid: booking.bookingUid,
data: booking.auditData,
})),
actor,
organizationId,
source,
operationId,
context,
});
}
}