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>
This commit is contained in:
Hariom Balhara
2025-12-09 14:29:41 +05:30
committed by GitHub
co-authored by Devin AI
parent 257a49ce88
commit f350542d0d
16 changed files with 1041 additions and 34 deletions
@@ -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).
---
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<TranslationWithParams> {
return { key: "booking_audit_action.accepted" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AcceptedAuditDisplayData {
const { fields } = storedData;
return {
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
};
}
}
export type AcceptedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AcceptedAuditDisplayData = {
previousStatus: string | null;
newStatus: string | null;
};
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<TranslationWithParams> {
return { key: "booking_audit_action.attendee_added" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): 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<typeof fieldsSchemaV1>;
export type AttendeeAddedAuditDisplayData = {
addedAttendees: string[];
};
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<TranslationWithParams> {
return { key: "booking_audit_action.attendee_no_show_updated" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeNoShowUpdatedAuditDisplayData {
const { fields } = storedData;
return {
noShowAttendee: fields.noShowAttendee.new,
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
};
}
}
export type AttendeeNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AttendeeNoShowUpdatedAuditDisplayData = {
noShowAttendee: boolean;
previousNoShowAttendee: boolean | null;
};
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<TranslationWithParams> {
return { key: "booking_audit_action.attendee_removed" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): 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<typeof fieldsSchemaV1>;
export type AttendeeRemovedAuditDisplayData = {
removedAttendees: string[];
};
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<TranslationWithParams> {
return { key: "booking_audit_action.cancelled" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): 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<typeof fieldsSchemaV1>;
export type CancelledAuditDisplayData = {
cancellationReason: string | null;
previousReason: string | null;
cancelledBy: string | null;
previousCancelledBy: string | null;
previousStatus: string | null;
newStatus: string | null;
};
@@ -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<TranslationWithParams> {
return { key: "booking_audit_action.created" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): 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,
};
}
}
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<TranslationWithParams> {
return { key: "booking_audit_action.host_no_show_updated" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): HostNoShowUpdatedAuditDisplayData {
const { fields } = storedData;
return {
noShowHost: fields.noShowHost.new,
previousNoShowHost: fields.noShowHost.old ?? null,
};
}
}
export type HostNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type HostNoShowUpdatedAuditDisplayData = {
noShowHost: boolean;
previousNoShowHost: boolean | null;
};
@@ -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</1>")
*/
export type TranslationWithParams = {
key: string;
params?: Record<string, string | number>;
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<TStoredFieldsSchema> }): unknown;
getDisplayJson?(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): 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<TStoredFieldsSchema> }): Promise<TranslationWithParams>;
/**
* Migrate old version data to latest version
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
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<typeof fieldsSchemaV1>;
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
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<typeof fieldsSchemaV1> }): ReassignmentAuditDisplayData {
const { fields } = storedData;
return {
newAssignedToId: fields.assignedToId.new,
reassignmentReason: fields.reassignmentReason.new ?? null,
};
}
}
export type ReassignmentAuditData = z.infer<typeof fieldsSchemaV1>;
export type ReassignmentAuditDisplayData = {
newAssignedToId: number;
reassignmentReason: string | null;
};
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<TranslationWithParams> {
return { key: "booking_audit_action.rejected" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): 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<typeof fieldsSchemaV1>;
export type RejectedAuditDisplayData = {
rejectionReason: string | null;
previousReason: string | null;
previousStatus: string | null;
newStatus: string | null;
};
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
readonly VERSION = 1;
public static readonly TYPE = "RESCHEDULE_REQUESTED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = RescheduleRequestedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = RescheduleRequestedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof RescheduleRequestedAuditActionService.latestFieldsSchema,
typeof RescheduleRequestedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: RescheduleRequestedAuditActionService.latestFieldsSchema,
storedDataSchema: RescheduleRequestedAuditActionService.storedDataSchema,
});
}
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<TranslationWithParams> {
return { key: "booking_audit_action.reschedule_requested" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduleRequestedAuditDisplayData {
const { fields } = storedData;
return {
reason: fields.cancellationReason.new ?? null,
requestedBy: fields.cancelledBy.new ?? null,
};
}
}
export type RescheduleRequestedAuditData = z.infer<typeof fieldsSchemaV1>;
export type RescheduleRequestedAuditDisplayData = {
reason: string | null;
requestedBy: string | null;
};
@@ -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<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
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<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
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<typeof fieldsSchemaV1> }): 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<T extends { data: unknown }>({
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<typeof fieldsSchemaV1>;
export type RescheduledAuditDisplayData = {
previousStartTime: string | null;
newStartTime: string | null;
previousEndTime: string | null;
newEndTime: string | null;
rescheduledToUid: string | null;
};
@@ -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(),
});
@@ -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",
},
],
},
},
});
});