Skip to content

Commit f350542

Browse files
feat: add booking audit action services for lib/actions folder (calcom#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>
1 parent 257a49c commit f350542

16 files changed

Lines changed: 1040 additions & 33 deletions

packages/features/booking-audit/ARCHITECTURE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,20 +361,20 @@ Used when a booking status changes to accepted.
361361
#### ATTENDEE_ADDED
362362
```typescript
363363
{
364-
addedAttendees // { old: null, new: ["email@example.com", ...] }
364+
attendees // { old: ["email1@example.com", ...], new: ["email1@example.com", "email2@example.com", ...] }
365365
}
366366
```
367367
368-
Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state.
368+
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).
369369
370370
#### ATTENDEE_REMOVED
371371
```typescript
372372
{
373-
removedAttendees // { old: null, new: ["email@example.com", ...] }
373+
attendees // { old: ["email1@example.com", ...], new: ["email2@example.com", ...] }
374374
}
375375
```
376376
377-
Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state.
377+
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).
378378
379379
---
380380
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { z } from "zod";
2+
3+
import { StringChangeSchema } from "../common/changeSchemas";
4+
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
5+
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
6+
7+
/**
8+
* Accepted Audit Action Service
9+
* Handles ACCEPTED action with per-action versioning
10+
*/
11+
12+
// Module-level because it is passed to IAuditActionService type outside the class scope
13+
const fieldsSchemaV1 = z.object({
14+
status: StringChangeSchema,
15+
});
16+
17+
export class AcceptedAuditActionService
18+
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
19+
readonly VERSION = 1;
20+
public static readonly TYPE = "ACCEPTED" as const;
21+
private static dataSchemaV1 = z.object({
22+
version: z.literal(1),
23+
fields: fieldsSchemaV1,
24+
});
25+
private static fieldsSchemaV1 = fieldsSchemaV1;
26+
public static readonly latestFieldsSchema = fieldsSchemaV1;
27+
// Union of all versions
28+
public static readonly storedDataSchema = AcceptedAuditActionService.dataSchemaV1;
29+
// Union of all versions
30+
public static readonly storedFieldsSchema = AcceptedAuditActionService.fieldsSchemaV1;
31+
private helper: AuditActionServiceHelper<
32+
typeof AcceptedAuditActionService.latestFieldsSchema,
33+
typeof AcceptedAuditActionService.storedDataSchema
34+
>;
35+
36+
constructor() {
37+
this.helper = new AuditActionServiceHelper({
38+
latestVersion: this.VERSION,
39+
latestFieldsSchema: AcceptedAuditActionService.latestFieldsSchema,
40+
storedDataSchema: AcceptedAuditActionService.storedDataSchema,
41+
});
42+
}
43+
44+
getVersionedData(fields: unknown) {
45+
return this.helper.getVersionedData(fields);
46+
}
47+
48+
parseStored(data: unknown) {
49+
return this.helper.parseStored(data);
50+
}
51+
52+
getVersion(data: unknown): number {
53+
return this.helper.getVersion(data);
54+
}
55+
56+
migrateToLatest(data: unknown) {
57+
// V1-only: validate and return as-is (no migration needed)
58+
const validated = fieldsSchemaV1.parse(data);
59+
return { isMigrated: false, latestData: validated };
60+
}
61+
62+
async getDisplayTitle(): Promise<TranslationWithParams> {
63+
return { key: "booking_audit_action.accepted" };
64+
}
65+
66+
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AcceptedAuditDisplayData {
67+
const { fields } = storedData;
68+
return {
69+
previousStatus: fields.status.old ?? null,
70+
newStatus: fields.status.new ?? null,
71+
};
72+
}
73+
}
74+
75+
export type AcceptedAuditData = z.infer<typeof fieldsSchemaV1>;
76+
77+
export type AcceptedAuditDisplayData = {
78+
previousStatus: string | null;
79+
newStatus: string | null;
80+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { z } from "zod";
2+
3+
import { StringArrayChangeSchema } from "../common/changeSchemas";
4+
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
5+
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
6+
7+
/**
8+
* Attendee Added Audit Action Service
9+
* Handles ATTENDEE_ADDED action with per-action versioning
10+
*/
11+
12+
// Module-level because it is passed to IAuditActionService type outside the class scope
13+
const fieldsSchemaV1 = z.object({
14+
attendees: StringArrayChangeSchema,
15+
});
16+
17+
export class AttendeeAddedAuditActionService
18+
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
19+
readonly VERSION = 1;
20+
public static readonly TYPE = "ATTENDEE_ADDED" as const;
21+
private static dataSchemaV1 = z.object({
22+
version: z.literal(1),
23+
fields: fieldsSchemaV1,
24+
});
25+
private static fieldsSchemaV1 = fieldsSchemaV1;
26+
public static readonly latestFieldsSchema = fieldsSchemaV1;
27+
// Union of all versions
28+
public static readonly storedDataSchema = AttendeeAddedAuditActionService.dataSchemaV1;
29+
// Union of all versions
30+
public static readonly storedFieldsSchema = AttendeeAddedAuditActionService.fieldsSchemaV1;
31+
private helper: AuditActionServiceHelper<
32+
typeof AttendeeAddedAuditActionService.latestFieldsSchema,
33+
typeof AttendeeAddedAuditActionService.storedDataSchema
34+
>;
35+
36+
constructor() {
37+
this.helper = new AuditActionServiceHelper({
38+
latestVersion: this.VERSION,
39+
latestFieldsSchema: AttendeeAddedAuditActionService.latestFieldsSchema,
40+
storedDataSchema: AttendeeAddedAuditActionService.storedDataSchema,
41+
});
42+
}
43+
44+
getVersionedData(fields: unknown) {
45+
return this.helper.getVersionedData(fields);
46+
}
47+
48+
parseStored(data: unknown) {
49+
return this.helper.parseStored(data);
50+
}
51+
52+
getVersion(data: unknown): number {
53+
return this.helper.getVersion(data);
54+
}
55+
56+
migrateToLatest(data: unknown) {
57+
// V1-only: validate and return as-is (no migration needed)
58+
const validated = fieldsSchemaV1.parse(data);
59+
return { isMigrated: false, latestData: validated };
60+
}
61+
62+
async getDisplayTitle(): Promise<TranslationWithParams> {
63+
return { key: "booking_audit_action.attendee_added" };
64+
}
65+
66+
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeAddedAuditDisplayData {
67+
const { fields } = storedData;
68+
const previousAttendeesSet = new Set(fields.attendees.old ?? []);
69+
const addedAttendees = fields.attendees.new.filter(
70+
(email) => !previousAttendeesSet.has(email)
71+
);
72+
return {
73+
addedAttendees,
74+
};
75+
}
76+
}
77+
78+
export type AttendeeAddedAuditData = z.infer<typeof fieldsSchemaV1>;
79+
80+
export type AttendeeAddedAuditDisplayData = {
81+
addedAttendees: string[];
82+
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { z } from "zod";
2+
3+
import { BooleanChangeSchema } from "../common/changeSchemas";
4+
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
5+
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
6+
7+
/**
8+
* Attendee No-Show Updated Audit Action Service
9+
* Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning
10+
*/
11+
12+
// Module-level because it is passed to IAuditActionService type outside the class scope
13+
const fieldsSchemaV1 = z.object({
14+
noShowAttendee: BooleanChangeSchema,
15+
});
16+
17+
export class AttendeeNoShowUpdatedAuditActionService
18+
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
19+
readonly VERSION = 1;
20+
public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const;
21+
private static dataSchemaV1 = z.object({
22+
version: z.literal(1),
23+
fields: fieldsSchemaV1,
24+
});
25+
private static fieldsSchemaV1 = fieldsSchemaV1;
26+
public static readonly latestFieldsSchema = fieldsSchemaV1;
27+
// Union of all versions
28+
public static readonly storedDataSchema = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1;
29+
// Union of all versions
30+
public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1;
31+
private helper: AuditActionServiceHelper<
32+
typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
33+
typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema
34+
>;
35+
36+
constructor() {
37+
this.helper = new AuditActionServiceHelper({
38+
latestVersion: this.VERSION,
39+
latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
40+
storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema,
41+
});
42+
}
43+
44+
getVersionedData(fields: unknown) {
45+
return this.helper.getVersionedData(fields);
46+
}
47+
48+
parseStored(data: unknown) {
49+
return this.helper.parseStored(data);
50+
}
51+
52+
getVersion(data: unknown): number {
53+
return this.helper.getVersion(data);
54+
}
55+
56+
migrateToLatest(data: unknown) {
57+
// V1-only: validate and return as-is (no migration needed)
58+
const validated = fieldsSchemaV1.parse(data);
59+
return { isMigrated: false, latestData: validated };
60+
}
61+
62+
async getDisplayTitle(): Promise<TranslationWithParams> {
63+
return { key: "booking_audit_action.attendee_no_show_updated" };
64+
}
65+
66+
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeNoShowUpdatedAuditDisplayData {
67+
const { fields } = storedData;
68+
return {
69+
noShowAttendee: fields.noShowAttendee.new,
70+
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
71+
};
72+
}
73+
}
74+
75+
export type AttendeeNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
76+
77+
export type AttendeeNoShowUpdatedAuditDisplayData = {
78+
noShowAttendee: boolean;
79+
previousNoShowAttendee: boolean | null;
80+
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { z } from "zod";
2+
3+
import { StringArrayChangeSchema } from "../common/changeSchemas";
4+
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
5+
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
6+
7+
/**
8+
* Attendee Removed Audit Action Service
9+
* Handles ATTENDEE_REMOVED action with per-action versioning
10+
*/
11+
12+
// Module-level because it is passed to IAuditActionService type outside the class scope
13+
const fieldsSchemaV1 = z.object({
14+
attendees: StringArrayChangeSchema,
15+
});
16+
17+
export class AttendeeRemovedAuditActionService
18+
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
19+
readonly VERSION = 1;
20+
public static readonly TYPE = "ATTENDEE_REMOVED" as const;
21+
private static dataSchemaV1 = z.object({
22+
version: z.literal(1),
23+
fields: fieldsSchemaV1,
24+
});
25+
private static fieldsSchemaV1 = fieldsSchemaV1;
26+
public static readonly latestFieldsSchema = fieldsSchemaV1;
27+
// Union of all versions
28+
public static readonly storedDataSchema = AttendeeRemovedAuditActionService.dataSchemaV1;
29+
// Union of all versions
30+
public static readonly storedFieldsSchema = AttendeeRemovedAuditActionService.fieldsSchemaV1;
31+
private helper: AuditActionServiceHelper<
32+
typeof AttendeeRemovedAuditActionService.latestFieldsSchema,
33+
typeof AttendeeRemovedAuditActionService.storedDataSchema
34+
>;
35+
36+
constructor() {
37+
this.helper = new AuditActionServiceHelper({
38+
latestVersion: this.VERSION,
39+
latestFieldsSchema: AttendeeRemovedAuditActionService.latestFieldsSchema,
40+
storedDataSchema: AttendeeRemovedAuditActionService.storedDataSchema,
41+
});
42+
}
43+
44+
getVersionedData(fields: unknown) {
45+
return this.helper.getVersionedData(fields);
46+
}
47+
48+
parseStored(data: unknown) {
49+
return this.helper.parseStored(data);
50+
}
51+
52+
getVersion(data: unknown): number {
53+
return this.helper.getVersion(data);
54+
}
55+
56+
migrateToLatest(data: unknown) {
57+
// V1-only: validate and return as-is (no migration needed)
58+
const validated = fieldsSchemaV1.parse(data);
59+
return { isMigrated: false, latestData: validated };
60+
}
61+
62+
async getDisplayTitle(): Promise<TranslationWithParams> {
63+
return { key: "booking_audit_action.attendee_removed" };
64+
}
65+
66+
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeRemovedAuditDisplayData {
67+
const { fields } = storedData;
68+
// Note: fields.attendees stores the state change (old -> new), not the removed attendees directly
69+
// old = attendees before removal, new = remaining attendees after removal
70+
const remainingAttendeesSet = new Set(fields.attendees.new ?? []);
71+
// Compute removed attendees: those in old but not in new (remaining)
72+
const removedAttendees = (fields.attendees.old ?? []).filter(
73+
(email) => !remainingAttendeesSet.has(email)
74+
);
75+
return {
76+
removedAttendees,
77+
};
78+
}
79+
}
80+
81+
export type AttendeeRemovedAuditData = z.infer<typeof fieldsSchemaV1>;
82+
83+
export type AttendeeRemovedAuditDisplayData = {
84+
removedAttendees: string[];
85+
};

0 commit comments

Comments
 (0)