Files
cal.diy/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts
T
2802310268 feat: Add infrastructure for no-show audit integration (#27187)
* feat: Add infrastructure for no-show audit integration

- Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action
- Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema
- Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED
- Update BookingAuditTaskConsumer and BookingAuditViewerService
- Add AttendeeRepository methods for no-show queries
- Update IAuditActionService interface with values array support
- Update locales with no-show audit translation keys

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService

- Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService
- Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit
- Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit
- Update BookingEventHandlerService.ts to use onNoShowUpdated
- Add integration tests for NoShowUpdatedAuditActionService

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Add data migration step for deprecated no-show enum values

Addresses Cubic AI review feedback (confidence 9/10): The migration now
includes an UPDATE statement to convert existing records using the
deprecated 'host_no_show_updated' or 'attendee_no_show_updated' enum
values to the new unified 'no_show_updated' value before the type cast.
This prevents migration failures if any existing data uses the old values.

Co-Authored-By: unknown <>

* fix: Use CASE expression in USING clause for enum migration

Fixes PostgreSQL error 'unsafe use of new value of enum type' by avoiding
the ADD VALUE statement and instead using a CASE expression in the ALTER
TABLE USING clause to convert deprecated enum values (host_no_show_updated,
attendee_no_show_updated) to the new unified value (no_show_updated) during
the type conversion.

Co-Authored-By: unknown <>

* fix: Replace hardcoded color with semantic text-success class

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove color class completely from display fields

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 08:47:26 -03:00

420 lines
14 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 { NoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService";
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 OnNoShowUpdatedParams = BaseBookingEventParams<NoShowUpdatedAuditData>;
type OnBookingRejectedParams = BaseBookingEventParams<RejectedAuditData>;
type OnAttendeeRemovedParams = BaseBookingEventParams<AttendeeRemovedAuditData>;
type OnReassignmentParams = BaseBookingEventParams<ReassignmentAuditData>;
type OnLocationChangedParams = BaseBookingEventParams<LocationChangedAuditData>;
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 onNoShowUpdated(params: OnNoShowUpdatedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueNoShowUpdatedAudit({
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 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,
});
}
}