## What does this PR do? Integrates booking audit logging for the edit location functionality, following the pattern established in PR #26046 (booking creation/rescheduling audit). This PR adds audit logging when a booking's location is changed through: 1. **Web app** (tRPC handler): `packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts` 2. **API v2**: `apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location.service.ts` ### Changes: - Added `actionSource` as a **required** parameter to `editLocationHandler` (no fallback) - Added optional `userUuid` parameter (defaults to logged-in user's uuid) - Added `ValidActionSource` type that excludes "UNKNOWN" for client-facing APIs - Captures old location before update for audit data - Calls `BookingEventHandlerService.onLocationChanged()` after successful location update - Web app uses `actionSource: "WEBAPP"`, API v2 uses `actionSource: "API_V2"` - Updated router to explicitly pass `actionSource: "WEBAPP"` - Updated test to pass `actionSource: "WEBAPP"` - **API v2**: Uses NestJS dependency injection pattern with `BookingEventHandlerService` injected via constructor ### Updates since last revision: - **Created `BookingEventHandlerModule`** (`apps/api/v2/src/lib/modules/booking-event-handler.module.ts`) to encapsulate `BookingEventHandlerService` and its dependencies (Logger, TaskerService, HashedLinkService, BookingAuditProducerService) - Updated both bookings modules (2024-04-15 and 2024-08-13) to import `BookingEventHandlerModule` instead of listing individual providers - This reduces code duplication and makes dependency management cleaner ## 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 - using existing audit infrastructure that is already tested. ## How should this be tested? 1. Update a booking's location through the web app 2. Update a booking's location through API v2 3. Verify audit logs are created with: - Correct `bookingUid` - Correct `actor` (user who made the change) - Correct `source` ("WEBAPP" or "API_V2") - Correct `auditData.location.old` and `auditData.location.new` values ## 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 ## Human Review Checklist - [ ] Verify all callers of `editLocationHandler` pass `actionSource` (router updated, test updated) - [ ] Verify `organizationId` derivation is correct in both handlers (tRPC uses `booking.user?.profiles?.[0]?.organizationId`, API v2 uses `existingBookingHost.organizationId`) - [ ] Confirm audit call placement after location update is intentional (audit failures would fail the operation even though location was already updated) - [ ] Note: API v2 has its own implementation and does NOT reuse `editLocationHandler` - this is correct - [ ] Verify `BookingEventHandlerModule` properly exports `BookingEventHandlerService` and is imported in both bookings modules - [ ] Verify the `updateBookingLocationInDb` return value (`{ updatedLocation }`) is destructured and used correctly for audit data - [ ] Verify API v2 uses `bookingLocation` for audit `new` value, while tRPC uses `updatedLocation` from DB update --- Link to Devin run: https://app.devin.ai/sessions/fd1d439779674050a26ea3fa7d799943 Requested by: @hariombalhara
435 lines
15 KiB
TypeScript
435 lines
15 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) {
|
|
try {
|
|
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
|
|
await this.bookingAuditProducerService.queueLocationChangedAudit({
|
|
bookingUid,
|
|
actor,
|
|
organizationId,
|
|
source,
|
|
operationId,
|
|
data: auditData,
|
|
context,
|
|
});
|
|
} catch (error) {
|
|
this.log.error("Error while onLocationChanged", safeStringify(error));
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
async onBulkBookingsCreated(params: {
|
|
bookings: Array<{
|
|
bookingUid: string;
|
|
auditData: CreatedAuditData;
|
|
}>;
|
|
actor: Actor;
|
|
organizationId: number | null;
|
|
operationId?: string | null;
|
|
source: ActionSource;
|
|
}) {
|
|
const { bookings, actor, organizationId, operationId, source } = params;
|
|
await this.bookingAuditProducerService.queueBulkCreatedAudit({
|
|
bookings: bookings.map((booking) => ({
|
|
bookingUid: booking.bookingUid,
|
|
data: booking.auditData,
|
|
})),
|
|
actor,
|
|
organizationId,
|
|
source,
|
|
operationId,
|
|
});
|
|
}
|
|
|
|
async onBulkBookingsRescheduled(params: {
|
|
bookings: Array<{
|
|
bookingUid: string;
|
|
auditData: RescheduledAuditData;
|
|
}>;
|
|
actor: Actor;
|
|
organizationId: number | null;
|
|
operationId?: string | null;
|
|
source: ActionSource;
|
|
}) {
|
|
const { bookings, actor, organizationId, operationId, source } = params;
|
|
await this.bookingAuditProducerService.queueBulkRescheduledAudit({
|
|
bookings: bookings.map((booking) => ({
|
|
bookingUid: booking.bookingUid,
|
|
data: booking.auditData,
|
|
})),
|
|
actor,
|
|
organizationId,
|
|
source,
|
|
operationId,
|
|
});
|
|
}
|
|
|
|
async onBulkBookingsRejected(params: {
|
|
bookings: Array<{
|
|
bookingUid: string;
|
|
auditData: RejectedAuditData;
|
|
}>;
|
|
actor: Actor;
|
|
organizationId: number | null;
|
|
operationId?: string | null;
|
|
source: ActionSource;
|
|
context?: BookingAuditContext;
|
|
}) {
|
|
const { bookings, actor, organizationId, operationId, source, context } = params;
|
|
await this.bookingAuditProducerService.queueBulkRejectedAudit({
|
|
bookings: bookings.map((booking) => ({
|
|
bookingUid: booking.bookingUid,
|
|
data: booking.auditData,
|
|
})),
|
|
actor,
|
|
organizationId,
|
|
source,
|
|
operationId,
|
|
context,
|
|
});
|
|
}
|
|
}
|