Skip to content

Commit c04402e

Browse files
feat: [Booking Audit Stack - 1] Add Booking Audit System foundation (database schema and repositories) (calcom#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
1 parent 075d209 commit c04402e

8 files changed

Lines changed: 1099 additions & 0 deletions

File tree

packages/features/booking-audit/ARCHITECTURE.md

Lines changed: 808 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export type AuditActorType = "USER" | "GUEST" | "ATTENDEE" | "SYSTEM";
2+
3+
type AuditActor = {
4+
id: string;
5+
type: AuditActorType;
6+
userUuid: string | null;
7+
attendeeId: number | null;
8+
email: string | null;
9+
phone: string | null;
10+
name: string | null;
11+
createdAt: Date;
12+
}
13+
export interface IAuditActorRepository {
14+
findByUserUuid(userUuid: string): Promise<AuditActor | null>;
15+
findSystemActorOrThrow(): Promise<AuditActor>;
16+
}
17+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED"
2+
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"
3+
export type BookingAuditCreateInput = {
4+
bookingUid: string;
5+
actorId: string;
6+
action: BookingAuditAction;
7+
data: unknown;
8+
createdAt: Date;
9+
type: BookingAuditType;
10+
timestamp: Date;
11+
}
12+
13+
type BookingAudit = {
14+
id: string;
15+
bookingUid: string;
16+
actorId: string;
17+
action: string;
18+
data: unknown;
19+
createdAt: Date;
20+
}
21+
22+
export interface IBookingAuditRepository {
23+
/**
24+
* Creates a new booking audit record
25+
*/
26+
create(bookingAudit: BookingAuditCreateInput): Promise<BookingAudit>;
27+
}
28+
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { PrismaClient } from "@calcom/prisma/client";
2+
import type { IAuditActorRepository } from "./IAuditActorRepository";
3+
4+
const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
5+
6+
type Dependencies = {
7+
prismaClient: PrismaClient;
8+
}
9+
export class PrismaAuditActorRepository implements IAuditActorRepository {
10+
constructor(private readonly deps: Dependencies) { }
11+
async findByUserUuid(userUuid: string) {
12+
return this.deps.prismaClient.auditActor.findUnique({
13+
where: { userUuid },
14+
});
15+
}
16+
17+
async findSystemActorOrThrow() {
18+
const actor = await this.deps.prismaClient.auditActor.findUnique({
19+
where: { id: SYSTEM_ACTOR_ID },
20+
});
21+
22+
if (!actor) {
23+
throw new Error("System actor not found");
24+
}
25+
26+
return actor;
27+
}
28+
}
29+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { PrismaClient } from "@calcom/prisma";
2+
3+
import type { IBookingAuditRepository, BookingAuditCreateInput } from "./IBookingAuditRepository";
4+
5+
type Dependencies = {
6+
prismaClient: PrismaClient;
7+
}
8+
export class PrismaBookingAuditRepository implements IBookingAuditRepository {
9+
constructor(private readonly deps: Dependencies) { }
10+
11+
async create(bookingAudit: BookingAuditCreateInput) {
12+
return this.deps.prismaClient.bookingAudit.create({
13+
data: bookingAudit,
14+
});
15+
}
16+
}
17+
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
-- CreateEnum
2+
CREATE TYPE "public"."BookingAuditType" AS ENUM ('record_created', 'record_updated', 'record_deleted');
3+
4+
-- CreateEnum
5+
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');
6+
7+
-- CreateEnum
8+
CREATE TYPE "public"."AuditActorType" AS ENUM ('user', 'guest', 'attendee', 'system');
9+
10+
-- CreateTable
11+
CREATE TABLE "public"."AuditActor" (
12+
"id" TEXT NOT NULL,
13+
"type" "public"."AuditActorType" NOT NULL,
14+
"userUuid" UUID,
15+
"attendeeId" INTEGER,
16+
"email" TEXT,
17+
"phone" TEXT,
18+
"name" TEXT,
19+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
20+
21+
CONSTRAINT "AuditActor_pkey" PRIMARY KEY ("id")
22+
);
23+
24+
-- CreateTable
25+
CREATE TABLE "public"."BookingAudit" (
26+
"id" UUID NOT NULL,
27+
"bookingUid" TEXT NOT NULL,
28+
"actorId" TEXT NOT NULL,
29+
"type" "public"."BookingAuditType" NOT NULL,
30+
"action" "public"."BookingAuditAction" NOT NULL,
31+
"timestamp" TIMESTAMP(3) NOT NULL,
32+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
33+
"updatedAt" TIMESTAMP(3) NOT NULL,
34+
"data" JSONB,
35+
36+
CONSTRAINT "BookingAudit_pkey" PRIMARY KEY ("id")
37+
);
38+
39+
-- CreateIndex
40+
CREATE INDEX "AuditActor_email_idx" ON "public"."AuditActor"("email");
41+
42+
-- CreateIndex
43+
CREATE INDEX "AuditActor_userUuid_idx" ON "public"."AuditActor"("userUuid");
44+
45+
-- CreateIndex
46+
CREATE INDEX "AuditActor_attendeeId_idx" ON "public"."AuditActor"("attendeeId");
47+
48+
-- CreateIndex
49+
CREATE UNIQUE INDEX "AuditActor_userUuid_key" ON "public"."AuditActor"("userUuid");
50+
51+
-- CreateIndex
52+
CREATE UNIQUE INDEX "AuditActor_attendeeId_key" ON "public"."AuditActor"("attendeeId");
53+
54+
-- CreateIndex
55+
CREATE UNIQUE INDEX "AuditActor_email_key" ON "public"."AuditActor"("email");
56+
57+
-- CreateIndex
58+
CREATE UNIQUE INDEX "AuditActor_phone_key" ON "public"."AuditActor"("phone");
59+
60+
-- CreateIndex
61+
CREATE INDEX "BookingAudit_actorId_idx" ON "public"."BookingAudit"("actorId");
62+
63+
-- CreateIndex
64+
CREATE INDEX "BookingAudit_bookingUid_idx" ON "public"."BookingAudit"("bookingUid");
65+
66+
-- CreateIndex
67+
CREATE INDEX "BookingAudit_timestamp_idx" ON "public"."BookingAudit"("timestamp");
68+
69+
-- AddForeignKey
70+
ALTER TABLE "public"."BookingAudit" ADD CONSTRAINT "BookingAudit_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "public"."AuditActor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Insert system actor with predefined UUID
2+
-- This actor is used for system-initiated booking actions
3+
INSERT INTO "AuditActor" (
4+
id,
5+
type,
6+
"userUuid",
7+
"attendeeId",
8+
email,
9+
phone,
10+
name,
11+
"createdAt"
12+
)
13+
VALUES (
14+
'00000000-0000-0000-0000-000000000000',
15+
'system',
16+
NULL,
17+
NULL,
18+
NULL,
19+
NULL,
20+
'System',
21+
NOW()
22+
)
23+
ON CONFLICT (id) DO NOTHING;

packages/prisma/schema.prisma

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2627,6 +2627,113 @@ model RolePermission {
26272627
@@index([action])
26282628
}
26292629

2630+
enum BookingAuditType {
2631+
RECORD_CREATED @map("record_created")
2632+
RECORD_UPDATED @map("record_updated")
2633+
RECORD_DELETED @map("record_deleted")
2634+
}
2635+
2636+
enum BookingAuditAction {
2637+
// Booking lifecycle
2638+
CREATED @map("created")
2639+
2640+
// Status changes
2641+
CANCELLED @map("cancelled")
2642+
ACCEPTED @map("accepted")
2643+
REJECTED @map("rejected")
2644+
PENDING @map("pending")
2645+
AWAITING_HOST @map("awaiting_host")
2646+
RESCHEDULED @map("rescheduled")
2647+
2648+
// Attendee management
2649+
ATTENDEE_ADDED @map("attendee_added")
2650+
ATTENDEE_REMOVED @map("attendee_removed")
2651+
2652+
// Assignment/Reassignment (keep integration version - simpler)
2653+
REASSIGNMENT @map("reassignment")
2654+
2655+
// Meeting details
2656+
LOCATION_CHANGED @map("location_changed")
2657+
2658+
// No-show tracking
2659+
HOST_NO_SHOW_UPDATED @map("host_no_show_updated")
2660+
ATTENDEE_NO_SHOW_UPDATED @map("attendee_no_show_updated")
2661+
2662+
// Rescheduling
2663+
RESCHEDULE_REQUESTED @map("reschedule_requested")
2664+
}
2665+
2666+
enum AuditActorType {
2667+
USER @map("user") // Registered Cal.com user (stored here for audit retention even after user deletion)
2668+
GUEST @map("guest") // Non-registered user
2669+
ATTENDEE @map("attendee") // Has Attendee record with us
2670+
SYSTEM @map("system") // Automated actions
2671+
}
2672+
2673+
model AuditActor {
2674+
id String @id @default(uuid())
2675+
type AuditActorType
2676+
2677+
// References for different actor types (soft references, no FK constraints)
2678+
// These fields intentionally do NOT have foreign key constraints to preserve audit trail integrity:
2679+
// - When a User or Attendee is deleted, their AuditActor record persists with these IDs intact
2680+
// - This maintains immutable audit history even after source records are removed
2681+
userUuid String? @db.Uuid // For USER type - references User.uuid without FK constraint
2682+
attendeeId Int? // For ATTENDEE type - references Attendee.id without FK constraint
2683+
2684+
// Identity fields - only for GUEST/SYSTEM(System too might not have all) type. Attendee and User maintain their own identity fields.
2685+
// They could be set as anonymized for User/Attendee record as well when they are deleted to preserve the audit trail.
2686+
email String?
2687+
phone String?
2688+
name String?
2689+
2690+
createdAt DateTime @default(now())
2691+
bookingAudits BookingAudit[]
2692+
2693+
// TODO: Add pseudonymizedAt and related fields when we anonymize the data on deletion
2694+
@@unique([userUuid])
2695+
@@unique([attendeeId])
2696+
@@unique([email]) // Prevent duplicate email actors
2697+
@@unique([phone]) // Prevent duplicate phone actors
2698+
@@index([email])
2699+
@@index([userUuid])
2700+
@@index([attendeeId])
2701+
}
2702+
2703+
model BookingAudit {
2704+
id String @id @default(uuid(7)) @db.Uuid
2705+
// bookingUid is stored as a plain string (not a foreign key relation) to preserve the audit trail
2706+
// even after the booking is deleted. This is intentional for audit log integrity:
2707+
// - Audit logs are immutable historical records that should persist independently
2708+
// - When a booking is deleted, we still need to know which booking the audit log belonged to
2709+
// - Using a plain string instead of a relation prevents bookingUid from becoming NULL on booking deletion
2710+
// - This allows users to view complete audit history for deleted bookings
2711+
bookingUid String
2712+
2713+
// Actor who performed the action (USER, GUEST, or SYSTEM)
2714+
// Stored in AuditActor table to maintain audit trail even after user deletion
2715+
actorId String
2716+
// Restrict onDelete to prevent deletion of audit actor if there are any booking audits associated with it
2717+
actor AuditActor @relation(fields: [actorId], references: [id], onDelete: Restrict)
2718+
2719+
type BookingAuditType
2720+
action BookingAuditAction
2721+
2722+
// Timestamp of the actual booking change (business event time)
2723+
// Important: May differ from createdAt if audit is processed asynchronously
2724+
timestamp DateTime
2725+
2726+
// Database record timestamps
2727+
createdAt DateTime @default(now())
2728+
updatedAt DateTime @updatedAt
2729+
2730+
data Json?
2731+
2732+
@@index([actorId])
2733+
@@index([bookingUid])
2734+
@@index([timestamp])
2735+
}
2736+
26302737
enum PhoneNumberSubscriptionStatus {
26312738
ACTIVE
26322739
PAST_DUE

0 commit comments

Comments
 (0)