From f350542d0dad2f809b10acd46982903f000f48dd Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 9 Dec 2025 14:29:41 +0530 Subject: [PATCH] feat: add booking audit action services for lib/actions folder (#25720) Extract lib/actions folder changes from booking-audit-more-infra branch: - AcceptedAuditActionService - AttendeeAddedAuditActionService - AttendeeNoShowUpdatedAuditActionService - AttendeeRemovedAuditActionService - CancelledAuditActionService - CreatedAuditActionService (modified) - HostNoShowUpdatedAuditActionService - IAuditActionService (modified) - LocationChangedAuditActionService - ReassignmentAuditActionService - RejectedAuditActionService - RescheduleRequestedAuditActionService - RescheduledAuditActionService Also includes common/changeSchemas.ts required for type-checks to pass. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../features/booking-audit/ARCHITECTURE.md | 8 +- .../lib/actions/AcceptedAuditActionService.ts | 80 +++++++++++++ .../AttendeeAddedAuditActionService.ts | 82 +++++++++++++ ...AttendeeNoShowUpdatedAuditActionService.ts | 80 +++++++++++++ .../AttendeeRemovedAuditActionService.ts | 85 +++++++++++++ .../actions/CancelledAuditActionService.ts | 90 ++++++++++++++ .../lib/actions/CreatedAuditActionService.ts | 15 ++- .../HostNoShowUpdatedAuditActionService.ts | 80 +++++++++++++ .../lib/actions/IAuditActionService.ts | 32 ++++- .../LocationChangedAuditActionService.ts | 79 ++++++++++++ .../actions/ReassignmentAuditActionService.ts | 91 ++++++++++++++ .../lib/actions/RejectedAuditActionService.ts | 85 +++++++++++++ .../RescheduleRequestedAuditActionService.ts | 82 +++++++++++++ .../actions/RescheduledAuditActionService.ts | 112 ++++++++++++++++++ .../booking-audit/lib/common/changeSchemas.ts | 27 +++++ .../service/booking-audit.integration-test.ts | 47 ++++---- 16 files changed, 1041 insertions(+), 34 deletions(-) create mode 100644 packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/common/changeSchemas.ts diff --git a/packages/features/booking-audit/ARCHITECTURE.md b/packages/features/booking-audit/ARCHITECTURE.md index 602225a490..f2c3cd42af 100644 --- a/packages/features/booking-audit/ARCHITECTURE.md +++ b/packages/features/booking-audit/ARCHITECTURE.md @@ -361,20 +361,20 @@ Used when a booking status changes to accepted. #### ATTENDEE_ADDED ```typescript { - addedAttendees // { old: null, new: ["email@example.com", ...] } + attendees // { old: ["email1@example.com", ...], new: ["email1@example.com", "email2@example.com", ...] } } ``` -Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state. +Tracks attendee(s) that were added in this action. The field stores the state change: `old` contains attendees before addition, `new` contains attendees after addition. The actual added attendees are computed as the difference (new - old). #### ATTENDEE_REMOVED ```typescript { - removedAttendees // { old: null, new: ["email@example.com", ...] } + attendees // { old: ["email1@example.com", ...], new: ["email2@example.com", ...] } } ``` -Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state. +Tracks attendee(s) that were removed in this action. The field stores the state change: `old` contains attendees before removal, `new` contains remaining attendees after removal. The actual removed attendees are computed as the difference (old - new). --- diff --git a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts new file mode 100644 index 0000000000..52a387fd31 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Accepted Audit Action Service + * Handles ACCEPTED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + status: StringChangeSchema, +}); + +export class AcceptedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "ACCEPTED" 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 = AcceptedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = AcceptedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof AcceptedAuditActionService.latestFieldsSchema, + typeof AcceptedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: AcceptedAuditActionService.latestFieldsSchema, + storedDataSchema: AcceptedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.accepted" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): AcceptedAuditDisplayData { + const { fields } = storedData; + return { + previousStatus: fields.status.old ?? null, + newStatus: fields.status.new ?? null, + }; + } +} + +export type AcceptedAuditData = z.infer; + +export type AcceptedAuditDisplayData = { + previousStatus: string | null; + newStatus: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts new file mode 100644 index 0000000000..40e5d30b7a --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { StringArrayChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Attendee Added Audit Action Service + * Handles ATTENDEE_ADDED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + attendees: StringArrayChangeSchema, +}); + +export class AttendeeAddedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "ATTENDEE_ADDED" 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 = AttendeeAddedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = AttendeeAddedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof AttendeeAddedAuditActionService.latestFieldsSchema, + typeof AttendeeAddedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: AttendeeAddedAuditActionService.latestFieldsSchema, + storedDataSchema: AttendeeAddedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.attendee_added" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeAddedAuditDisplayData { + const { fields } = storedData; + const previousAttendeesSet = new Set(fields.attendees.old ?? []); + const addedAttendees = fields.attendees.new.filter( + (email) => !previousAttendeesSet.has(email) + ); + return { + addedAttendees, + }; + } +} + +export type AttendeeAddedAuditData = z.infer; + +export type AttendeeAddedAuditDisplayData = { + addedAttendees: string[]; +}; diff --git a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts new file mode 100644 index 0000000000..662ec76e6d --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Attendee No-Show Updated Audit Action Service + * Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + noShowAttendee: BooleanChangeSchema, +}); + +export class AttendeeNoShowUpdatedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" 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 = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema, + typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema, + storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.attendee_no_show_updated" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeNoShowUpdatedAuditDisplayData { + const { fields } = storedData; + return { + noShowAttendee: fields.noShowAttendee.new, + previousNoShowAttendee: fields.noShowAttendee.old ?? null, + }; + } +} + +export type AttendeeNoShowUpdatedAuditData = z.infer; + +export type AttendeeNoShowUpdatedAuditDisplayData = { + noShowAttendee: boolean; + previousNoShowAttendee: boolean | null; +}; diff --git a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts new file mode 100644 index 0000000000..d90356ced1 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { StringArrayChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Attendee Removed Audit Action Service + * Handles ATTENDEE_REMOVED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + attendees: StringArrayChangeSchema, +}); + +export class AttendeeRemovedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "ATTENDEE_REMOVED" 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 = AttendeeRemovedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = AttendeeRemovedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof AttendeeRemovedAuditActionService.latestFieldsSchema, + typeof AttendeeRemovedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: AttendeeRemovedAuditActionService.latestFieldsSchema, + storedDataSchema: AttendeeRemovedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.attendee_removed" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeRemovedAuditDisplayData { + const { fields } = storedData; + // Note: fields.attendees stores the state change (old -> new), not the removed attendees directly + // old = attendees before removal, new = remaining attendees after removal + const remainingAttendeesSet = new Set(fields.attendees.new ?? []); + // Compute removed attendees: those in old but not in new (remaining) + const removedAttendees = (fields.attendees.old ?? []).filter( + (email) => !remainingAttendeesSet.has(email) + ); + return { + removedAttendees, + }; + } +} + +export type AttendeeRemovedAuditData = z.infer; + +export type AttendeeRemovedAuditDisplayData = { + removedAttendees: string[]; +}; diff --git a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts new file mode 100644 index 0000000000..fef3683181 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Cancelled Audit Action Service + * Handles CANCELLED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + cancellationReason: StringChangeSchema, + cancelledBy: StringChangeSchema, + status: StringChangeSchema, +}); + +export class CancelledAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "CANCELLED" 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 = CancelledAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = CancelledAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof CancelledAuditActionService.latestFieldsSchema, + typeof CancelledAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: CancelledAuditActionService.latestFieldsSchema, + storedDataSchema: CancelledAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.cancelled" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): CancelledAuditDisplayData { + const { fields } = storedData; + return { + cancellationReason: fields.cancellationReason.new ?? null, + previousReason: fields.cancellationReason.old ?? null, + cancelledBy: fields.cancelledBy.new ?? null, + previousCancelledBy: fields.cancelledBy.old ?? null, + previousStatus: fields.status.old ?? null, + newStatus: fields.status.new ?? null, + }; + } +} + +export type CancelledAuditData = z.infer; + +export type CancelledAuditDisplayData = { + cancellationReason: string | null; + previousReason: string | null; + cancelledBy: string | null; + previousCancelledBy: string | null; + previousStatus: string | null; + newStatus: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index caed606bc7..a2cccf38da 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { BookingStatus } from "@calcom/prisma/enums"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; /** * Created Audit Action Service @@ -22,7 +22,7 @@ export class CreatedAuditActionService implements IAuditActionService< typeof fieldsSchemaV1 > { readonly VERSION = 1; - public static readonly TYPE = "CREATED"; + public static readonly TYPE = "CREATED" as const; private static dataSchemaV1 = z.object({ version: z.literal(1), fields: fieldsSchemaV1, @@ -61,11 +61,16 @@ export class CreatedAuditActionService implements IAuditActionService< return { isMigrated: false, latestData: validated }; } + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.created" }; + } + getDisplayJson(storedData: { version: number; fields: z.infer }): CreatedAuditDisplayData { + const { fields } = storedData; return { - startTime: new Date(storedData.fields.startTime).toISOString(), - endTime: new Date(storedData.fields.endTime).toISOString(), - status: storedData.fields.status, + startTime: new Date(fields.startTime).toISOString(), + endTime: new Date(fields.endTime).toISOString(), + status: fields.status, }; } } diff --git a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts new file mode 100644 index 0000000000..b1d08faeed --- /dev/null +++ b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Host No-Show Updated Audit Action Service + * Handles HOST_NO_SHOW_UPDATED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + noShowHost: BooleanChangeSchema, +}); + +export class HostNoShowUpdatedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "HOST_NO_SHOW_UPDATED" 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 = HostNoShowUpdatedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = HostNoShowUpdatedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema, + typeof HostNoShowUpdatedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: HostNoShowUpdatedAuditActionService.latestFieldsSchema, + storedDataSchema: HostNoShowUpdatedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.host_no_show_updated" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): HostNoShowUpdatedAuditDisplayData { + const { fields } = storedData; + return { + noShowHost: fields.noShowHost.new, + previousNoShowHost: fields.noShowHost.old ?? null, + }; + } +} + +export type HostNoShowUpdatedAuditData = z.infer; + +export type HostNoShowUpdatedAuditDisplayData = { + noShowHost: boolean; + previousNoShowHost: boolean | null; +}; diff --git a/packages/features/booking-audit/lib/actions/IAuditActionService.ts b/packages/features/booking-audit/lib/actions/IAuditActionService.ts index aac3f555f4..e49e1bed7f 100644 --- a/packages/features/booking-audit/lib/actions/IAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/IAuditActionService.ts @@ -1,5 +1,25 @@ import { z } from "zod"; +/** + * Represents a component that can be interpolated into translations + * Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.) + */ +export type TranslationComponent = { + type: "link"; + href: string; +}; + +/** + * Represents a translation key with optional interpolation params and components + * Used for dynamic display titles that need to be translated with context + * Components are used for clickable links within translations (e.g., "Rescheduled to <1>New Booking") + */ +export type TranslationWithParams = { + key: string; + params?: Record; + components?: TranslationComponent[]; +}; + /** * Interface for Audit Action Services * @@ -43,10 +63,20 @@ export interface IAuditActionService< /** * Get flattened JSON data for display (fields only, no version wrapper) + * Optional - implement only if custom display formatting is needed * @param storedData - Parsed stored data { version, fields } * @returns The fields object without version wrapper and we decide what fields to show to the client */ - getDisplayJson(storedData: { version: number; fields: z.infer }): unknown; + getDisplayJson?(storedData: { version: number; fields: z.infer }): unknown; + + /** + * Get the display title for the audit action + * Returns a translation key with optional interpolation params for dynamic titles + * (e.g., "Booking reassigned to John Doe" instead of just "Reassignment") + * @param storedData - Parsed stored data { version, fields } + * @returns Translation key with optional interpolation params + */ + getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise; /** * Migrate old version data to latest version diff --git a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts new file mode 100644 index 0000000000..b8199cf8c4 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; + +import { getHumanReadableLocationValue } from "@calcom/app-store/locations"; +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import { getTranslation } from "@calcom/lib/server/i18n"; +/** + * Location Changed Audit Action Service + * Handles LOCATION_CHANGED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + location: StringChangeSchema, +}); + +export class LocationChangedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "LOCATION_CHANGED" 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 = LocationChangedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = LocationChangedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof LocationChangedAuditActionService.latestFieldsSchema, + typeof LocationChangedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: LocationChangedAuditActionService.latestFieldsSchema, + storedDataSchema: LocationChangedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + const { fields } = storedData; + // TODO: Ideally we want to translate the location label to the user's locale + // We currently don't accept requesting user's translate fn here, fix it later. + const t = await getTranslation("en", "common"); + + const fromLocation = getHumanReadableLocationValue(fields.location.old, t); + const toLocation = getHumanReadableLocationValue(fields.location.new, t); + + return { + key: "booking_audit_action.location_changed_from_to", + params: { fromLocation, toLocation }, + }; + } +} + +export type LocationChangedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts new file mode 100644 index 0000000000..37066914f5 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; + +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Reassignment Audit Action Service + * Handles REASSIGNMENT action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + assignedToId: NumberChangeSchema, + assignedById: NumberChangeSchema, + reassignmentReason: StringChangeSchema, + userPrimaryEmail: StringChangeSchema.optional(), + title: StringChangeSchema.optional(), +}); + +export class ReassignmentAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "REASSIGNMENT" 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 = ReassignmentAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = ReassignmentAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof ReassignmentAuditActionService.latestFieldsSchema, + typeof ReassignmentAuditActionService.storedDataSchema + >; + + constructor(private userRepository: UserRepository) { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema, + storedDataSchema: ReassignmentAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + const { fields } = storedData; + const user = await this.userRepository.findById({ id: fields.assignedToId.new }); + const reassignedToName = user?.name || "Unknown"; + return { + key: "booking_audit_action.booking_reassigned_to_host", + params: { host: reassignedToName }, + }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): ReassignmentAuditDisplayData { + const { fields } = storedData; + return { + newAssignedToId: fields.assignedToId.new, + reassignmentReason: fields.reassignmentReason.new ?? null, + }; + } +} + +export type ReassignmentAuditData = z.infer; + +export type ReassignmentAuditDisplayData = { + newAssignedToId: number; + reassignmentReason: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts new file mode 100644 index 0000000000..91bfb9d951 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Rejected Audit Action Service + * Handles REJECTED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + rejectionReason: StringChangeSchema, + status: StringChangeSchema, +}); + +export class RejectedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "REJECTED" 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 = RejectedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = RejectedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof RejectedAuditActionService.latestFieldsSchema, + typeof RejectedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: RejectedAuditActionService.latestFieldsSchema, + storedDataSchema: RejectedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.rejected" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): RejectedAuditDisplayData { + const { fields } = storedData; + return { + rejectionReason: fields.rejectionReason.new ?? null, + previousReason: fields.rejectionReason.old ?? null, + previousStatus: fields.status.old ?? null, + newStatus: fields.status.new ?? null, + }; + } +} + +export type RejectedAuditData = z.infer; + +export type RejectedAuditDisplayData = { + rejectionReason: string | null; + previousReason: string | null; + previousStatus: string | null; + newStatus: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts new file mode 100644 index 0000000000..8afe5123df --- /dev/null +++ b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Reschedule Requested Audit Action Service + * Handles RESCHEDULE_REQUESTED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + cancellationReason: StringChangeSchema, + cancelledBy: StringChangeSchema, + rescheduled: BooleanChangeSchema.optional(), +}); + +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, + }); + 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); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.reschedule_requested" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): RescheduleRequestedAuditDisplayData { + const { fields } = storedData; + return { + reason: fields.cancellationReason.new ?? null, + requestedBy: fields.cancelledBy.new ?? null, + }; + } +} + +export type RescheduleRequestedAuditData = z.infer; + +export type RescheduleRequestedAuditDisplayData = { + reason: string | null; + requestedBy: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts new file mode 100644 index 0000000000..950670cbd4 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Rescheduled Audit Action Service + * Handles RESCHEDULED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + startTime: StringChangeSchema, + endTime: StringChangeSchema, + rescheduledToUid: StringChangeSchema, +}); + +export class RescheduledAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "RESCHEDULED" 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 = RescheduledAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = RescheduledAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof RescheduledAuditActionService.latestFieldsSchema, + typeof RescheduledAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: RescheduledAuditActionService.latestFieldsSchema, + storedDataSchema: RescheduledAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(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 }; + } + + async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + const rescheduledToUid = storedData.fields.rescheduledToUid.new; + return { + key: "booking_audit_action.rescheduled", + components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}` }] : undefined, + }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): RescheduledAuditDisplayData { + const { fields } = storedData; + return { + previousStartTime: fields.startTime.old ?? null, + newStartTime: fields.startTime.new ?? null, + previousEndTime: fields.endTime.old ?? null, + newEndTime: fields.endTime.new ?? null, + rescheduledToUid: fields.rescheduledToUid.new ?? null, + }; + } + + /** + * Finds the rescheduled log that created a specific booking + * by matching the rescheduledToUid field with the target booking UID + * @param rescheduledLogs - Array of rescheduled audit logs to search through + * @param rescheduledToBookingUid - The UID of the booking that was created from the reschedule + * @returns The matching log or null if not found + */ + getMatchingLog({ + rescheduledLogs, + rescheduledToBookingUid, + }: { + rescheduledLogs: T[]; + rescheduledToBookingUid: string; + }): T | null { + return rescheduledLogs.find((log) => { + const parsedData = this.parseStored(log.data); + return parsedData.fields.rescheduledToUid.new === rescheduledToBookingUid; + }) ?? null; + } +} + +export type RescheduledAuditData = z.infer; + +export type RescheduledAuditDisplayData = { + previousStartTime: string | null; + newStartTime: string | null; + previousEndTime: string | null; + newEndTime: string | null; + rescheduledToUid: string | null; +}; diff --git a/packages/features/booking-audit/lib/common/changeSchemas.ts b/packages/features/booking-audit/lib/common/changeSchemas.ts new file mode 100644 index 0000000000..50025e8ec2 --- /dev/null +++ b/packages/features/booking-audit/lib/common/changeSchemas.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +/** + * Common change schemas for audit data + * These represent old -> new value transitions + */ + +export const StringChangeSchema = z.object({ + old: z.string().nullable(), + new: z.string().nullable(), +}); + +export const BooleanChangeSchema = z.object({ + old: z.boolean().nullable(), + new: z.boolean(), +}); + +export const StringArrayChangeSchema = z.object({ + old: z.array(z.string()).nullable(), + new: z.array(z.string()), +}); + +export const NumberChangeSchema = z.object({ + old: z.number().nullable(), + new: z.number(), +}); + diff --git a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts index 8ad0a8b2bd..d9f1b2be5e 100644 --- a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts +++ b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts @@ -56,24 +56,7 @@ describe("Booking Audit Integration", () => { }); testEventTypeId = eventType.id; - // Create test booking - const startTime = new Date(); - const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); - testBookingUid = `test-booking-${timestamp}-${randomSuffix}`; - - const testBooking = await prisma.booking.create({ - data: { - uid: testBookingUid, - title: "Test Booking", - startTime, - endTime, - userId: testUserId, - eventTypeId: testEventTypeId, - status: BookingStatus.ACCEPTED, - }, - }); - - // Create test attendee user for permission tests + // Create test attendee user for permission tests (needed before booking creation) const attendeeUser = await prisma.user.create({ data: { email: `test-attendee-${timestamp}-${randomSuffix}@example.com`, @@ -84,13 +67,29 @@ describe("Booking Audit Integration", () => { testAttendeeUserId = attendeeUser.id; testAttendeeEmail = attendeeUser.email; - // Add attendee to booking - await prisma.attendee.create({ + // Create test booking with attendee in single atomic operation + const startTime = new Date(); + const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); + testBookingUid = `test-booking-${timestamp}-${randomSuffix}`; + + await prisma.booking.create({ data: { - email: testAttendeeEmail, - name: "Test Attendee", - timeZone: "UTC", - bookingId: testBooking.id, + uid: testBookingUid, + title: "Test Booking", + startTime, + endTime, + userId: testUserId, + eventTypeId: testEventTypeId, + status: BookingStatus.ACCEPTED, + attendees: { + create: [ + { + email: testAttendeeEmail, + name: "Test Attendee", + timeZone: "UTC", + }, + ], + }, }, }); });