diff --git a/packages/features/booking-audit/ARCHITECTURE.md b/packages/features/booking-audit/ARCHITECTURE.md new file mode 100644 index 0000000000..1751bc8196 --- /dev/null +++ b/packages/features/booking-audit/ARCHITECTURE.md @@ -0,0 +1,808 @@ +# Booking Audit System - Database Architecture +Based on https://github.com/calcom/cal.com/pull/22817 + +Note: This architecture is not in production yet, so we can make any changes we want to it without worrying about backwards compatibility. + +## Overview + +The Booking Audit System tracks all actions and changes related to bookings in Cal.com. The architecture is built around two core tables (`AuditActor` and `BookingAudit`) that work together to maintain a complete, immutable audit trail. + +## Database Tables + +### 1. AuditActor Table + +The `AuditActor` table stores information about entities that perform actions on bookings. It maintains historical records even after users are deleted. + +```prisma +model AuditActor { + id String @id @default(uuid()) + type AuditActorType + + // References for different actor types (soft references, no FK constraints) + userUuid String? @db.Uuid // For USER type (nullable to allow user deletion) + attendeeId Int? // For ATTENDEE type(nullable to allow attendee deletion) + + // Identity fields - only for GUEST/SYSTEM(System too might not have all) type. Attendee and User maintain their own identity fields. + // They could be set as anonymized for User/Attendee record as well when they are deleted to preserve the audit trail. + email String? + phone String? + name String? + + createdAt DateTime @default(now()) + bookingAudits BookingAudit[] + + @@unique([userUuid]) + @@unique([attendeeId]) + @@unique([email]) // Prevent duplicate email actors + @@unique([phone]) // Prevent duplicate phone actors + @@index([email]) + @@index([userUuid]) + @@index([attendeeId]) + @@index([pseudonymizedAt]) // For compliance cleanup jobs +} +``` + +**Key Design Decisions:** +- **UUID Primary Key**: Uses UUIDs for distributed system compatibility +- **Soft References**: Both `userUuid` and `attendeeId` are soft references without foreign key constraints, allowing the audit trail to persist independently even after users or attendees are deleted +- **User UUID Reference**: Uses `User.uuid` instead of `User.id` for stable identification across distributed systems +- **Unique Constraints**: Prevents duplicate audit actors for the same user/email/phone +- **Multiple Identity Fields**: Supports different actor types (users, guests, attendees, system) +- **Extensible System Actors**: Architecture supports multiple system actors (e.g., Cron, Webhooks, API integrations, Background Workers) for granular tracking of automated operations +- **Identity Fields for Audit Retention**: Identity fields (email, phone, name) are primarily used for GUEST/SYSTEM actors. For USER and ATTENDEE types, these fields can be anonymized when the source record is deleted to preserve the audit trail while removing PII + +--- + +### 2. BookingAudit Table + +The `BookingAudit` table stores audit records for all booking-related actions. + +```prisma +model BookingAudit { + id String @id @default(uuid(7)) @db.Uuid + // bookingUid is stored as a plain string (not a foreign key relation) to preserve the audit trail + // even after the booking is deleted. This is intentional for audit log integrity: + // - Audit logs are immutable historical records that should persist independently + // - When a booking is deleted, we still need to know which booking the audit log belonged to + // - Using a plain string instead of a relation prevents bookingUid from becoming NULL on booking deletion + // - This allows users to view complete audit history for deleted bookings + bookingUid String + + // Actor who performed the action (USER, GUEST, or SYSTEM) + // Stored in AuditActor table to maintain audit trail even after user deletion + actorId String + // Restrict onDelete to prevent deletion of audit actor if there are any booking audits associated with it + actor AuditActor @relation(fields: [actorId], references: [id], onDelete: Restrict) + + type BookingAuditType + action BookingAuditAction + + // Timestamp of the actual booking change (business event time) + // Important: May differ from createdAt if audit is processed asynchronously + timestamp DateTime + + // Database record timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + data Json? + + @@index([actorId]) + @@index([bookingUid]) +} +``` + +**Key Design Decisions:** +- **UUID v7 Primary Key**: Uses `uuid(7)` for time-sortable IDs, providing chronological ordering and improved database performance +- **No Foreign Key to Booking**: `bookingUid` is stored as a plain string without a foreign key constraint to preserve audit trail integrity even after bookings are deleted +- **Restrict on Delete**: `onDelete: Restrict` prevents actor deletion if audit records exist +- **Required Action**: Every audit record must specify a business action, ensuring explicit tracking of what happened +- **Explicit Timestamp**: The `timestamp` field has no default and must be explicitly provided, representing when the business event actually occurred +- **Separate Database Timestamps**: `createdAt` and `updatedAt` track when the audit record itself was created/modified, distinct from the business event time +- **JSON Data Field**: Flexible schema for storing action-specific contextual data +- **Indexed Fields**: Efficient queries by `bookingUid` and `actorId` + + +**Protecting the Audit Trail:** +- **AuditActor Record Deletion Prevention**: The database will reject any attempt to delete an `AuditActor` record that has associated `BookingAudit` records, ensuring audit records never become orphaned +- **User Deletion Unaffected**: When a `User` is deleted from the system, their corresponding `AuditActor` record persists (with `userUuid` set to null), maintaining the complete audit history + + + +## Enums + +### AuditActorType + +Defines the type of entity performing an action: + +```prisma +enum AuditActorType { + USER @map("user") // Registered Cal.com user (stored here for audit retention even after user deletion) + // Considering renaming it to ANONYMOUS to avoid confusion with Guest of a booking + GUEST @map("guest") // Non-registered user + ATTENDEE @map("attendee") // Guest who booked (has Attendee record) + SYSTEM @map("system") // Automated actions +} +``` + +**Values:** +- **USER**: Registered Cal.com users who perform actions on bookings +- **GUEST**: Non-registered users (typically booking guests) +- **ATTENDEE**: Guests who have an Attendee record associated with a booking +- **SYSTEM**: Automated system actions (scheduled jobs, webhooks, etc.) + +--- + +### BookingAuditType + +Defines the category of audit record: + +```prisma +enum BookingAuditType { + RECORD_CREATED @map("record_created") + RECORD_UPDATED @map("record_updated") + RECORD_DELETED @map("record_deleted") +} +``` + +**Usage Pattern:** +- **RECORD_CREATED**: Initial booking creation +- **RECORD_UPDATED**: Any modification to existing booking (most common) +- **RECORD_DELETED**: Permanent deletion (rarely used, as bookings are typically cancelled) + +--- + +### BookingAuditAction + +Defines specific actions performed on bookings: + +```prisma +enum BookingAuditAction { + // Booking lifecycle + CREATED @map("created") + + // Status changes + CANCELLED @map("cancelled") + ACCEPTED @map("accepted") + REJECTED @map("rejected") + PENDING @map("pending") + AWAITING_HOST @map("awaiting_host") + RESCHEDULED @map("rescheduled") + + // Attendee management + ATTENDEE_ADDED @map("attendee_added") + ATTENDEE_REMOVED @map("attendee_removed") + + // Assignment/Reassignment + REASSIGNMENT @map("reassignment") + + // Meeting details + LOCATION_CHANGED @map("location_changed") + + // No-show tracking + HOST_NO_SHOW_UPDATED @map("host_no_show_updated") + ATTENDEE_NO_SHOW_UPDATED @map("attendee_no_show_updated") + + // Rescheduling + RESCHEDULE_REQUESTED @map("reschedule_requested") +} +``` + +**Action Categories:** + +1. **Booking Lifecycle**: Track creation events + - `CREATED` + +2. **Status Changes**: Track booking lifecycle transitions + - `CANCELLED`, `ACCEPTED`, `REJECTED`, `PENDING`, `AWAITING_HOST`, `RESCHEDULED` + +3. **Attendee Management**: Track changes to booking participants + - `ATTENDEE_ADDED`, `ATTENDEE_REMOVED` + +4. **Assignment/Reassignment**: Track booking host assignment changes + - `REASSIGNMENT` + +5. **Meeting Details**: Track changes to meeting logistics + - `LOCATION_CHANGED` + +6. **No-Show Tracking**: Track attendance issues + - `HOST_NO_SHOW_UPDATED`, `ATTENDEE_NO_SHOW_UPDATED` + +7. **Rescheduling**: Track reschedule requests + - `RESCHEDULE_REQUESTED` + +--- + +## Schema Structure - Change Tracking + +All audit actions follow a consistent structure for tracking changes. This structure ensures complete audit trail coverage by capturing both the old and new values for every tracked field. + +### Core Pattern + +Each action stores a flat object with all relevant fields. Each field tracks both old and new values: + +```typescript +{ + field1: { old: T | null, new: T }, + field2: { old: T | null, new: T }, + field3: { old: T | null, new: T } // Optional fields as needed +} +``` + +### Change Tracking + +**All fields use the same pattern** - `{ old: T | null, new: T }`: +- `old`: The previous value (null if the field didn't exist before) +- `new`: The new value after the change + +**Examples:** +```typescript +// Simple field changes +status: { old: "ACCEPTED", new: "CANCELLED" } +location: { old: "Zoom", new: "Google Meet" } + +// New fields (old is null) +cancellationReason: { old: null, new: "Client requested" } +``` + +### Semantic Clarity at Application Layer + +**Action Services decide what to display prominently.** Each Action Service has methods like `getDisplayDetails()` that determine: +- Which fields are most important to show by default +- Which fields should be available but not emphasized +- How to format the data for display + +This keeps the data structure simple while maintaining semantic clarity where it matters - in the UI. + +### Benefits + +1. **Complete Audit Trail**: Full before/after state captured for every change +2. **Self-Contained Records**: Each record has complete context without querying previous records +3. **Simple Structure**: Flat object, easy to work with and extend +4. **Better UI**: Action Services decide what to emphasize based on user needs +5. **State Reconstruction**: Can rebuild booking state at any point in the audit timeline +6. **Easier Debugging**: See exact state transitions in each record +7. **Type Safety**: Zod schemas validate the structure while keeping it flexible + +### Examples by Action + +#### Simple Action +```typescript +// LOCATION_CHANGED +{ + location: { old: "Zoom", new: "Google Meet" } +} +``` + +#### Action with Multiple Fields +```typescript +// CANCELLED +{ + cancellationReason: { old: null, new: "Client requested" }, + cancelledBy: { old: null, new: "user@example.com" }, + status: { old: "ACCEPTED", new: "CANCELLED" } +} +``` + +--- + +## JSON Data Schemas by Action + +The `BookingAudit.data` field stores action-specific context. Each action has its own schema defined in a dedicated Action Helper Service using Zod validation. + +### Booking Lifecycle Actions + +#### CREATED +Used when a booking is initially created. Records the complete state at creation time. + +```typescript +{ + startTime: string // ISO 8601 timestamp + endTime: string // ISO 8601 timestamp + status: BookingStatus // Actual booking status (any value from BookingStatus enum) +} +``` + +**Design Decision:** The `status` field accepts any `BookingStatus` value, not just the expected creation statuses (ACCEPTED, PENDING, AWAITING_HOST). This follows the principle of capturing reality rather than enforcing business rules in the audit layer. If a booking is ever created with an unexpected status due to a bug, we want to record that fact for debugging purposes rather than silently skip the audit record. + +**Note:** The CREATED action is unique - it captures the initial booking state at creation, so it doesn't use the `{ old, new }` tracking pattern. It's a flat object with just the initial values: `{ startTime, endTime, status }`. + +--- + +### Status Change Actions + +#### ACCEPTED +Used when a booking status changes to accepted. + +```typescript +{ + status // { old: "PENDING", new: "ACCEPTED" } +} +``` + +#### CANCELLED +```typescript +{ + cancellationReason, // { old: null, new: "Client requested" } + cancelledBy, // { old: null, new: "user@example.com" } + status // { old: "ACCEPTED", new: "CANCELLED" } +} +``` + +#### REJECTED +```typescript +{ + rejectionReason, // { old: null, new: "Does not meet requirements" } + status // { old: "PENDING", new: "REJECTED" } +} +``` + +#### RESCHEDULED +```typescript +{ + startTime, // { old: "2024-01-15T10:00:00Z", new: "2024-01-16T14:00:00Z" } + endTime // { old: "2024-01-15T11:00:00Z", new: "2024-01-16T15:00:00Z" } +} +``` + +#### RESCHEDULE_REQUESTED +```typescript +{ + cancellationReason, // { old: null, new: "Need to reschedule" } + cancelledBy, // { old: null, new: "user@example.com" } + rescheduled? // { old: false, new: true } - optional +} +``` + +--- + +### Attendee Management Actions + +#### ATTENDEE_ADDED +```typescript +{ + addedAttendees // { old: null, new: ["email@example.com", ...] } +} +``` + +Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state. + +#### ATTENDEE_REMOVED +```typescript +{ + removedAttendees // { old: null, new: ["email@example.com", ...] } +} +``` + +Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state. + +--- + +### Assignment/Reassignment Actions + +#### REASSIGNMENT +```typescript +{ + assignedToId, // { old: 123, new: 456 } + assignedById, // { old: 789, new: 789 } + reassignmentReason, // { old: null, new: "Coverage needed" } + userPrimaryEmail?, // { old: "old@cal.com", new: "new@cal.com" } - optional + title? // { old: "Meeting with A", new: "Meeting with B" } - optional +} +``` + +--- + +### Meeting Details Actions + +#### LOCATION_CHANGED +```typescript +{ + location // { old: "Zoom", new: "Google Meet" } +} +``` + +--- + +### No-Show Tracking Actions + +#### HOST_NO_SHOW_UPDATED +```typescript +{ + noShowHost // { old: false, new: true } +} +``` + +#### ATTENDEE_NO_SHOW_UPDATED +```typescript +{ + noShowAttendee // { old: false, new: true } +} +``` + +--- + +**Important Notes:** +- Each schema is strictly typed and validated using the corresponding Action Helper Service +- Actor information is **NOT** stored in the JSON data - it's captured through the `actorId` relation linking to the `Actor` table +- All action schemas are defined in `/packages/features/booking-audit/lib/actions/` + +--- + +### Supporting Schemas + +#### Change Tracking Pattern + +**All changes use the `{ old, new }` pattern:** + +Each field tracks both old and new values: +```typescript +fieldName: { + old: T | null, // Previous value (null if field didn't exist) + new: T // New value +} +``` + +**Benefits:** +- Complete before/after state in every record +- Self-contained audit entries (no need to query previous records) +- Clear state transitions +- Easier debugging and UI display +- Simple flat structure that's easy to work with + +--- + +## Table Relationships + +``` +AuditActor (1) ──────< (many) BookingAudit + ↑ + │ (soft reference, no FK) + ├──────────── User (via userUuid) + │ (soft reference, no FK) + └──────────── Attendee (via attendeeId) +``` + +**Relationship Details:** + +1. **AuditActor → BookingAudit**: One-to-Many (with FK constraint) + - Each AuditActor can have multiple audit records + - Each BookingAudit references exactly one AuditActor + - `onDelete: Restrict` prevents AuditActor deletion if audits exist + +2. **AuditActor → User**: Soft Reference (no FK constraint) + - `userUuid` references `User.uuid` but without foreign key constraint + - Nullable to preserve audits after user deletion + - Unique constraint ensures one AuditActor per User UUID + - Audit trail persists independently of user lifecycle + +3. **AuditActor → Attendee**: Soft Reference (no FK constraint) + - `attendeeId` references `Attendee.id` but without foreign key constraint + - Links guest actors to their booking attendee records + - Nullable to preserve audits after attendee deletion + - Unique constraint ensures one AuditActor per Attendee + - Audit trail persists independently of attendee lifecycle + +--- + +## Indexing Strategy + +### AuditActor Table Indexes + +```prisma +@@index([email]) // Fast lookups by guest email +@@index([userUuid]) // Fast lookups by user UUID +@@index([attendeeId]) // Fast lookups by attendee ID +``` + +**Query Patterns:** +- Find all actions by a user (via UUID) +- Find all actions by a guest email +- Find all actions by an attendee + +--- + +### BookingAudit Table Indexes + +```prisma +@@index([bookingId]) // Primary query pattern +@@index([actorId]) // Secondary query pattern +``` + +**Query Patterns:** +- **bookingId**: "Show me all audits for this booking" (most common) +- **actorId**: "Show me all actions by this actor" + +--- + +## Special Actors + +### SYSTEM Actor + +The system actor represents automated actions and has a fixed UUID: + +```typescript +const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; +``` + +**Used For:** +- Automated status changes +- System-generated meeting URLs +- Scheduled operations (cron jobs) +- Webhook-triggered actions +- Any action not directly initiated by a user or guest + +**Properties:** +- Type: `ActorType.SYSTEM` +- No userUuid, attendeeId, email, or phone +- Single instance across the entire system + +--- + +## Schema Version Management + +The audit system uses **per-action versioning** rather than global versioning. Each `BookingAuditAction` maintains its own schema version independently. + +### Why Per-Action Versioning? + +Different actions have different data requirements: +- `CANCELLED` needs `cancellationReason` +- `RESCHEDULED` needs new `startTime` and `endTime` +- `ATTENDEE_ADDED` needs `attendee` information +- `REASSIGNMENT` needs assignment context + +When we update the schema for one action, we don't want to affect other actions. + +### Implementation Approach + +Each action has a dedicated Action Service that manages its own versioning independently. The service defines: + +1. **Schema Definition**: Zod schemas for data validation +2. **Schema Version**: Each action maintains its own VERSION constant +3. **Nested Structure**: Version stored separately from audit data: `{ version, data: {} }` +4. **Type Separation**: Distinct types for input (no version) and stored format (with version) +5. **Validation**: Type-safe validation of audit data +6. **Display Logic**: How to render the audit record in the UI + +**Example Action Services:** +- `CreatedAuditActionService` → Handles `CREATED` action +- `CancelledAuditActionService` → Handles `CANCELLED` action +- `RescheduledAuditActionService` → Handles `RESCHEDULED` action +- `ReassignmentAuditActionService` → Handles `REASSIGNMENT` action + +### Version Storage Structure + +Audit data is stored with a nested structure that separates version metadata from actual audit data: + +```typescript +{ + version: 1, + data: { + cancellationReason: { old: null, new: "Client requested" }, + cancelledBy: { old: null, new: "user@cal.com" }, + status: { old: "ACCEPTED", new: "CANCELLED" } + } +} +``` + +**Benefits of Nested Structure:** +- Clear separation between metadata (version) and actual audit data +- Makes it easy to extract just the data fields for display +- Version handling is transparent to end users +- Schema evolution is self-documenting + +**Key Points:** +- Callers pass unversioned data (just the fields) +- `parse()` automatically wraps input with version before storing +- `parseStored()` validates stored data including version +- Display methods receive full stored record but only show data fields +- Type system enforces correct usage (input vs stored types) + +### Benefits of Per-Action Versioning + +- **Independent Evolution**: Update one action's schema without affecting others +- **Explicit Changes**: Version increments are tied to specific business operations +- **No Migration Required**: Old records handled via discriminated unions +- **Clear History**: Can track schema changes per action type over time +- **Type Safety**: Each action has strongly-typed schemas for input and storage +- **Caller Simplicity**: Callers don't need to know about versioning +- **Display Isolation**: Version handling is internal to Action Services + + +When adding a new version (e.g., v2 with a new field): + +**Migration Steps:** +1. Create `dataSchemaV2` with new fields +2. Create `schemaV2` with `version: z.literal(2)` +3. Update `schema` to discriminated union supporting both v1 and v2 +4. Update `VERSION` constant to 2 +5. Update `parse()` to use v2 schema +6. Update display methods to handle both versions +7. No changes needed to callers or database + +--- + +## Design Principles + +### 1. Immutability +Audit records are append-only. Once created, they are never modified or deleted. This ensures: +- Complete historical accuracy +- Tamper-proof audit trail +- Compliance with audit requirements + +### 2. Historical Preservation +Actor information is preserved even after source records are deleted: +- User deletion doesn't remove AuditActor records - they persist with anonymized identity fields +- AuditActor table maintains historical records by nullifying PII fields (email, phone, name) when source records are deleted +- Audit trail remains complete and queryable even after user/attendee deletion + +### 3. Flexibility +The JSON `data` field provides schema flexibility: +- Action-specific context without database schema changes +- Easy addition of new fields for future actions +- Backward compatible with versioning + +### 4. Traceability +Every action is fully traceable: +- Who: Actor with type and identity +- What: Type and Action enums +- When: Explicit timestamp +- Why/How: Contextual data in JSON field + +### 5. Integrity +Database constraints ensure data quality: +- Foreign key constraints prevent orphaned records +- `onDelete: Restrict` protects audit integrity +- Unique constraints prevent duplicate actors +- Indexes ensure query performance + +### 6. Reality Over Enforcement +**The audit system records actual state, not expected state.** + +The audit system is designed to capture what actually happened, not to enforce business rules: + +- **Store Actual Values**: When recording state (e.g., booking status at creation), store the actual value from the database without validation or filtering +- **No Type Guards for State**: Avoid conditionally creating audit records based on whether the state matches expectations +- **Bug Detection**: If a booking is created with an unexpected status, record it—this becomes valuable debugging information +- **Single Source of Truth**: Let the business logic layer enforce rules; the audit layer's job is faithful recording + +**Example:** +```typescript +// ❌ BAD: Enforcing expected values +if (booking.status === 'ACCEPTED' || booking.status === 'PENDING' || booking.status === 'AWAITING_HOST') { + // Only audit if status is "expected" + await auditService.onBookingCreated(...); +} + +// ✅ GOOD: Recording actual state +await auditService.onBookingCreated(bookingId, userUuid, { + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + status: booking.status // Whatever it actually is +}); +``` + +**Benefits:** +- Audit trail shows real system behavior, including anomalies +- Easier to debug issues (incomplete audits hide problems) +- Schemas naturally evolve with business requirements +- No silent failures where audits are skipped + +**When to Validate:** +- Validate data structure (required fields, types) ✅ +- Do NOT validate business logic (expected values, state transitions) ❌ + +--- + +## 7. Compliance & Data Privacy + +**GDPR & HIPAA Compliance:** +- **AuditActor records are NOT deleted on user deletion** - Instead, AuditActor records persist with PII fields nullified to preserve the immutable audit trail as required by HIPAA §164.312(b) +- **Cal.com's HIPAA compliance** requires audit records to remain immutable and tamper-proof. The AuditActor table design ensures BookingAudit records are never modified, only the referenced AuditActor record has its PII fields anonymized +- **GDPR Article 17 compliance** is achieved through anonymization: When a user/guest requests deletion, set `userUuid=null`, `email=null`, `phone=null`, `name=null` on the AuditActor record +- **Retention Policy**: Application-level logic should handle retention policies and full anonymization after required retention periods + +**Implementation Pattern:** +```typescript +// On user deletion: Anonymize PII, don't delete the actor record +await prisma.auditActor.update({ + where: { userUuid: deletedUserUuid }, + data: { + userUuid: null, + email: null, + phone: null, + name: null, + pseudonymizedAt: new Date(), + scheduledDeletionDate: new Date(Date.now() + 7 * 365 * 24 * 60 * 60 * 1000) + } +}); +// Result: All BookingAudit records reference pseudonymized actor - audit trail preserved, immutable +``` + +--- + +## Service Layer + +### BookingEventHandlerService - Entry Point + +`BookingEventHandlerService` is the primary entry point for tracking any booking changes. It acts as a coordinator that: + +1. **Receives booking events** from various parts of the application (booking creation, status changes, updates, etc.) +2. **Relays to BookingAuditService** to create audit records +3. **Handles other side effects** such as webhooks, notifications, and workflow triggers + + +### BookingAuditService + +The audit system is accessed through `BookingAuditService`, which provides: + +### Convenience Methods + +- `onBookingCreated()` - Track booking creation +- `onBookingAccepted()` - Track acceptance +- `onBookingRejected()` - Track rejection +- `onBookingCancelled()` - Track cancellation +- `onBookingRescheduled()` - Track reschedule +- `onAttendeeAdded()` - Track attendee addition +- `onAttendeeRemoved()` - Track attendee removal +- `onLocationChanged()` - Track location changes +- `onHostNoShowUpdated()` - Track host no-show +- `onAttendeeNoShowUpdated()` - Track attendee no-show +- `onReassignment()` - Track booking reassignment +- `onRescheduleRequested()` - Track reschedule requests + +### Actor Management + +- `getOrCreateUserActor()` - Ensures User actors exist before creating audits +- Automatic AuditActor creation/lookup for registered users +- System actor for automated actions + +### Future: Trigger.dev Task Orchestration + +**Current Flow (Synchronous):** +``` +Booking Endpoint → BookingEventHandler.onBookingCreated() → await auditService, linkService, webhookService +``` + +**Future Flow (Async with Trigger.dev):** +``` +Booking Endpoint + ↓ +BookingEventHandler.onBookingCreated() [orchestrator] + ├─ tasks.trigger('bookingAudit', { bookingId, userUuid, data }) + ├─ tasks.trigger('invalidateHashedLink', { bookingId, hashedLink }) + ├─ tasks.trigger('sendNotifications', { bookingId, email, sms }) + ├─ tasks.trigger('triggerWorkflows', { bookingId, event: 'NEW_EVENT' }) + └─ Immediately returns to user (non-blocking) + +Trigger.dev Queue + ├─ Task: Booking Audit (with retries, monitoring) + ├─ Task: Hashed Link Invalidation (independent) + ├─ Task: Email & SMS Notifications (independent) + └─ Task: Workflow Triggers (independent) +``` + +**Key Principles:** +- **BookingEventHandler remains the single orchestrator** - Entry point for all side effects +- **Each task is independent** - One task failure doesn't block others +- **Persistent queue** - Trigger.dev handles retries, monitoring, and observability +- **Easy to add features** - New side effect = new task definition, no BookingEventHandler complexity +- **Immutable audit records** - Booking audit is just one of many tasks, preserving the immutability principle + +--- + +## Summary + +The Booking Audit System provides a robust, scalable architecture for tracking all booking-related actions. Key features include: + +- ✅ **Complete Audit Trail**: Every action tracked with full context +- ✅ **Historical Preservation**: Data retained even after deletions through PII anonymization +- ✅ **Flexible Schema**: JSON data supports evolution without migrations +- ✅ **Strong Integrity**: Database constraints ensure data quality +- ✅ **Performance**: Strategic indexes for common query patterns, UUID v7 for time-sortable IDs +- ✅ **HIPAA & GDPR Compliant**: Immutable audit records, anonymized actors, compliance-ready +- ✅ **Reality-Based Recording**: Captures actual state, aiding in debugging and analysis +- ✅ **Independent Audit Trail**: No foreign key to bookings ensures audit history persists after booking deletion + +This architecture supports compliance requirements (HIPAA §164.312(b), GDPR Article 17), debugging, analytics, and provides transparency for both users and administrators. + diff --git a/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts b/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts new file mode 100644 index 0000000000..51ff14ec29 --- /dev/null +++ b/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts @@ -0,0 +1,17 @@ +export type AuditActorType = "USER" | "GUEST" | "ATTENDEE" | "SYSTEM"; + +type AuditActor = { + id: string; + type: AuditActorType; + userUuid: string | null; + attendeeId: number | null; + email: string | null; + phone: string | null; + name: string | null; + createdAt: Date; +} +export interface IAuditActorRepository { + findByUserUuid(userUuid: string): Promise; + findSystemActorOrThrow(): Promise; +} + diff --git a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts new file mode 100644 index 0000000000..d75d6c7e5a --- /dev/null +++ b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts @@ -0,0 +1,28 @@ +export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED" +export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "CANCELLATION_REASON_UPDATED" | "REJECTION_REASON_UPDATED" | "ASSIGNMENT_REASON_UPDATED" | "REASSIGNMENT_REASON_UPDATED" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" +export type BookingAuditCreateInput = { + bookingUid: string; + actorId: string; + action: BookingAuditAction; + data: unknown; + createdAt: Date; + type: BookingAuditType; + timestamp: Date; +} + +type BookingAudit = { + id: string; + bookingUid: string; + actorId: string; + action: string; + data: unknown; + createdAt: Date; +} + +export interface IBookingAuditRepository { + /** + * Creates a new booking audit record + */ + create(bookingAudit: BookingAuditCreateInput): Promise; +} + diff --git a/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts b/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts new file mode 100644 index 0000000000..f2718b904d --- /dev/null +++ b/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts @@ -0,0 +1,29 @@ +import type { PrismaClient } from "@calcom/prisma/client"; +import type { IAuditActorRepository } from "./IAuditActorRepository"; + +const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; + +type Dependencies = { + prismaClient: PrismaClient; +} +export class PrismaAuditActorRepository implements IAuditActorRepository { + constructor(private readonly deps: Dependencies) { } + async findByUserUuid(userUuid: string) { + return this.deps.prismaClient.auditActor.findUnique({ + where: { userUuid }, + }); + } + + async findSystemActorOrThrow() { + const actor = await this.deps.prismaClient.auditActor.findUnique({ + where: { id: SYSTEM_ACTOR_ID }, + }); + + if (!actor) { + throw new Error("System actor not found"); + } + + return actor; + } +} + diff --git a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts new file mode 100644 index 0000000000..7a1c29b22b --- /dev/null +++ b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts @@ -0,0 +1,17 @@ +import type { PrismaClient } from "@calcom/prisma"; + +import type { IBookingAuditRepository, BookingAuditCreateInput } from "./IBookingAuditRepository"; + +type Dependencies = { + prismaClient: PrismaClient; +} +export class PrismaBookingAuditRepository implements IBookingAuditRepository { + constructor(private readonly deps: Dependencies) { } + + async create(bookingAudit: BookingAuditCreateInput) { + return this.deps.prismaClient.bookingAudit.create({ + data: bookingAudit, + }); + } +} + diff --git a/packages/prisma/migrations/20251115043236_add_audit_booking/migration.sql b/packages/prisma/migrations/20251115043236_add_audit_booking/migration.sql new file mode 100644 index 0000000000..70633dd858 --- /dev/null +++ b/packages/prisma/migrations/20251115043236_add_audit_booking/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "public"."BookingAuditType" AS ENUM ('record_created', 'record_updated', 'record_deleted'); + +-- CreateEnum +CREATE TYPE "public"."BookingAuditAction" AS ENUM ('created', 'cancelled', 'accepted', 'rejected', 'pending', 'awaiting_host', 'rescheduled', 'attendee_added', 'attendee_removed', 'reassignment', 'location_changed', 'host_no_show_updated', 'attendee_no_show_updated', 'reschedule_requested'); + +-- CreateEnum +CREATE TYPE "public"."AuditActorType" AS ENUM ('user', 'guest', 'attendee', 'system'); + +-- CreateTable +CREATE TABLE "public"."AuditActor" ( + "id" TEXT NOT NULL, + "type" "public"."AuditActorType" NOT NULL, + "userUuid" UUID, + "attendeeId" INTEGER, + "email" TEXT, + "phone" TEXT, + "name" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditActor_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."BookingAudit" ( + "id" UUID NOT NULL, + "bookingUid" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "type" "public"."BookingAuditType" NOT NULL, + "action" "public"."BookingAuditAction" NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "data" JSONB, + + CONSTRAINT "BookingAudit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AuditActor_email_idx" ON "public"."AuditActor"("email"); + +-- CreateIndex +CREATE INDEX "AuditActor_userUuid_idx" ON "public"."AuditActor"("userUuid"); + +-- CreateIndex +CREATE INDEX "AuditActor_attendeeId_idx" ON "public"."AuditActor"("attendeeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuditActor_userUuid_key" ON "public"."AuditActor"("userUuid"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuditActor_attendeeId_key" ON "public"."AuditActor"("attendeeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuditActor_email_key" ON "public"."AuditActor"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuditActor_phone_key" ON "public"."AuditActor"("phone"); + +-- CreateIndex +CREATE INDEX "BookingAudit_actorId_idx" ON "public"."BookingAudit"("actorId"); + +-- CreateIndex +CREATE INDEX "BookingAudit_bookingUid_idx" ON "public"."BookingAudit"("bookingUid"); + +-- CreateIndex +CREATE INDEX "BookingAudit_timestamp_idx" ON "public"."BookingAudit"("timestamp"); + +-- AddForeignKey +ALTER TABLE "public"."BookingAudit" ADD CONSTRAINT "BookingAudit_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "public"."AuditActor"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20251115054502_insert_system_actor/migration.sql b/packages/prisma/migrations/20251115054502_insert_system_actor/migration.sql new file mode 100644 index 0000000000..e0c96276d4 --- /dev/null +++ b/packages/prisma/migrations/20251115054502_insert_system_actor/migration.sql @@ -0,0 +1,23 @@ +-- Insert system actor with predefined UUID +-- This actor is used for system-initiated booking actions +INSERT INTO "AuditActor" ( + id, + type, + "userUuid", + "attendeeId", + email, + phone, + name, + "createdAt" +) +VALUES ( + '00000000-0000-0000-0000-000000000000', + 'system', + NULL, + NULL, + NULL, + NULL, + 'System', + NOW() +) +ON CONFLICT (id) DO NOTHING; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4aaacf584f..6b70715756 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2627,6 +2627,113 @@ model RolePermission { @@index([action]) } +enum BookingAuditType { + RECORD_CREATED @map("record_created") + RECORD_UPDATED @map("record_updated") + RECORD_DELETED @map("record_deleted") +} + +enum BookingAuditAction { + // Booking lifecycle + CREATED @map("created") + + // Status changes + CANCELLED @map("cancelled") + ACCEPTED @map("accepted") + REJECTED @map("rejected") + PENDING @map("pending") + AWAITING_HOST @map("awaiting_host") + RESCHEDULED @map("rescheduled") + + // Attendee management + ATTENDEE_ADDED @map("attendee_added") + ATTENDEE_REMOVED @map("attendee_removed") + + // Assignment/Reassignment (keep integration version - simpler) + REASSIGNMENT @map("reassignment") + + // Meeting details + LOCATION_CHANGED @map("location_changed") + + // No-show tracking + HOST_NO_SHOW_UPDATED @map("host_no_show_updated") + ATTENDEE_NO_SHOW_UPDATED @map("attendee_no_show_updated") + + // Rescheduling + RESCHEDULE_REQUESTED @map("reschedule_requested") +} + +enum AuditActorType { + USER @map("user") // Registered Cal.com user (stored here for audit retention even after user deletion) + GUEST @map("guest") // Non-registered user + ATTENDEE @map("attendee") // Has Attendee record with us + SYSTEM @map("system") // Automated actions +} + +model AuditActor { + id String @id @default(uuid()) + type AuditActorType + + // References for different actor types (soft references, no FK constraints) + // These fields intentionally do NOT have foreign key constraints to preserve audit trail integrity: + // - When a User or Attendee is deleted, their AuditActor record persists with these IDs intact + // - This maintains immutable audit history even after source records are removed + userUuid String? @db.Uuid // For USER type - references User.uuid without FK constraint + attendeeId Int? // For ATTENDEE type - references Attendee.id without FK constraint + + // Identity fields - only for GUEST/SYSTEM(System too might not have all) type. Attendee and User maintain their own identity fields. + // They could be set as anonymized for User/Attendee record as well when they are deleted to preserve the audit trail. + email String? + phone String? + name String? + + createdAt DateTime @default(now()) + bookingAudits BookingAudit[] + + // TODO: Add pseudonymizedAt and related fields when we anonymize the data on deletion + @@unique([userUuid]) + @@unique([attendeeId]) + @@unique([email]) // Prevent duplicate email actors + @@unique([phone]) // Prevent duplicate phone actors + @@index([email]) + @@index([userUuid]) + @@index([attendeeId]) +} + +model BookingAudit { + id String @id @default(uuid(7)) @db.Uuid + // bookingUid is stored as a plain string (not a foreign key relation) to preserve the audit trail + // even after the booking is deleted. This is intentional for audit log integrity: + // - Audit logs are immutable historical records that should persist independently + // - When a booking is deleted, we still need to know which booking the audit log belonged to + // - Using a plain string instead of a relation prevents bookingUid from becoming NULL on booking deletion + // - This allows users to view complete audit history for deleted bookings + bookingUid String + + // Actor who performed the action (USER, GUEST, or SYSTEM) + // Stored in AuditActor table to maintain audit trail even after user deletion + actorId String + // Restrict onDelete to prevent deletion of audit actor if there are any booking audits associated with it + actor AuditActor @relation(fields: [actorId], references: [id], onDelete: Restrict) + + type BookingAuditType + action BookingAuditAction + + // Timestamp of the actual booking change (business event time) + // Important: May differ from createdAt if audit is processed asynchronously + timestamp DateTime + + // Database record timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + data Json? + + @@index([actorId]) + @@index([bookingUid]) + @@index([timestamp]) +} + enum PhoneNumberSubscriptionStatus { ACTIVE PAST_DUE