feat: [Booking Audit Stack - 1] Add Booking Audit System foundation (database schema and repositories) (#24838)

* feat: Implement Booking Audit System with database architecture and repository interfaces

- Added `ARCHITECTURE.md` detailing the design and structure of the Booking Audit System, including core tables `AuditActor` and `BookingAudit`.
- Created repository interfaces `IAuditActorRepository` and `IBookingAuditRepository` for managing audit actor and booking audit records.
- Implemented `PrismaAuditActorRepository` and `PrismaBookingAuditRepository` for database interactions.
- Defined enums for `BookingAuditType`, `BookingAuditAction`, and `AuditActorType` in the Prisma schema.
- Added migration scripts to create necessary database tables and enums for the audit system.

This commit establishes a robust framework for tracking booking-related actions, ensuring compliance and data integrity.

* feat(audit): add system actor migration
This commit is contained in:
Hariom Balhara
2025-11-19 08:21:34 -03:00
committed by GitHub
parent 075d209b6f
commit c04402e7ee
8 changed files with 1099 additions and 0 deletions
@@ -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.
@@ -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<AuditActor | null>;
findSystemActorOrThrow(): Promise<AuditActor>;
}
@@ -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<BookingAudit>;
}
@@ -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;
}
}
@@ -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,
});
}
}
@@ -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;
@@ -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;
+107
View File
@@ -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