diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/booking/logs/[bookinguid]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/booking/logs/[bookinguid]/page.tsx new file mode 100644 index 00000000000000..87d0e72f9cc87d --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/booking/logs/[bookinguid]/page.tsx @@ -0,0 +1,45 @@ +import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir"; +import type { PageProps } from "app/_types"; +import { _generateMetadata, getTranslate } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import BookingLogsView from "~/booking/logs/views/booking-logs-view"; + +export const generateMetadata = async ({ params }: { params: Promise<{ bookinguid: string }> }) => + await _generateMetadata( + (t) => t("booking_history"), + (t) => t("booking_history_description"), + undefined, + undefined, + `/booking/logs/${(await params).bookinguid}` + ); + +const Page = async ({ params }: PageProps) => { + const resolvedParams = await params; + const bookingUid = resolvedParams.bookinguid; + + if (!bookingUid || typeof bookingUid !== "string") { + redirect("/bookings/upcoming"); + } + + const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user?.id) { + redirect("/auth/login"); + } + + return ( + + + + ); +}; + +export default Page; + diff --git a/apps/web/modules/booking/logs/views/booking-logs-view.tsx b/apps/web/modules/booking/logs/views/booking-logs-view.tsx new file mode 100644 index 00000000000000..0be6db935e3457 --- /dev/null +++ b/apps/web/modules/booking/logs/views/booking-logs-view.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import dayjs from "@calcom/dayjs"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { Icon } from "@calcom/ui/components/icon"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; + +interface BookingLogsViewProps { + bookingUid: string; +} + +const _actionColorMap: Record = { + CREATED: "blue", + CANCELLED: "red", + ACCEPTED: "green", + REJECTED: "orange", + RESCHEDULED: "purple", + REASSIGNMENT: "yellow", + ATTENDEE_ADDED: "green", + ATTENDEE_REMOVED: "orange", + LOCATION_CHANGED: "blue", + HOST_NO_SHOW_UPDATED: "red", + ATTENDEE_NO_SHOW_UPDATED: "red", + RESCHEDULE_REQUESTED: "purple", +}; + +const actionDisplayMap: Record = { + CREATED: "Created", + CANCELLED: "Cancelled call", + ACCEPTED: "Accepted", + REJECTED: "Rejected", + RESCHEDULED: "Rescheduled call", + REASSIGNMENT: "Assigned", + ATTENDEE_ADDED: "Invited", + ATTENDEE_REMOVED: "Removed attendee", + LOCATION_CHANGED: "Location changed", + HOST_NO_SHOW_UPDATED: "Host no-show updated", + ATTENDEE_NO_SHOW_UPDATED: "Attendee no-show updated", + RESCHEDULE_REQUESTED: "Reschedule requested", +}; + +const getActionIcon = (action: string) => { + switch (action) { + case "CREATED": + return ; + case "CANCELLED": + case "REJECTED": + return ; + case "ACCEPTED": + return ; + case "RESCHEDULED": + case "RESCHEDULE_REQUESTED": + return ; + case "REASSIGNMENT": + case "ATTENDEE_ADDED": + case "ATTENDEE_REMOVED": + return ; + case "LOCATION_CHANGED": + return ; + default: + return ; + } +}; + +export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) { + const router = useRouter(); + const [expandedLogId, setExpandedLogId] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [typeFilter, setTypeFilter] = useState(null); + const [actorFilter, setActorFilter] = useState(null); + + const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({ + bookingUid, + }); + + const toggleExpand = (logId: string) => { + setExpandedLogId(expandedLogId === logId ? null : logId); + }; + + if (error) { + return ( +
+
+

Error loading booking logs

+

{error.message}

+ +
+
+ ); + } + + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + const auditLogs = data?.auditLogs || []; + + // Apply filters + const filteredLogs = auditLogs.filter((log) => { + const matchesSearch = + !searchTerm || + log.action.toLowerCase().includes(searchTerm.toLowerCase()) || + log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesType = !typeFilter || log.action === typeFilter; + const matchesActor = !actorFilter || log.actor.type === actorFilter; + + return matchesSearch && matchesType && matchesActor; + }); + + const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action))); + const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))); + + return ( +
+ {/* Header with Back Button */} +
+ +
+

Booking History

+

View all changes and events for this booking

+
+
+ + {/* Filters */} +
+
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ +
+ +
+ +
+
+ + {/* Audit Log List */} +
+ {filteredLogs.length === 0 ? ( +
+

No audit logs found

+
+ ) : ( + filteredLogs.map((log) => { + const isExpanded = expandedLogId === log.id; + const actionDisplay = actionDisplayMap[log.action] || log.action; + + return ( +
+ {/* Log Header */} +
+ {/* Icon */} +
{getActionIcon(log.action)}
+ + {/* Content */} +
+
+
+

{actionDisplay}

+
+ + + {log.actor.displayName} + + + + + {dayjs(log.timestamp).fromNow()} + +
+
+ + +
+
+
+ + {/* Expanded Details */} + {isExpanded && ( +
+
+
+ Type: + {log.type} +
+
+ Actor: + {log.actor.type} +
+
+ Timestamp: + + {dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")} + +
+ {log.actor.displayEmail && ( +
+ Actor Email: + {log.actor.displayEmail} +
+ )} +
+ + {/* Data JSON */} + {log.data && ( +
+

Details:

+
+                                                    {JSON.stringify(log.data, null, 2)}
+                                                
+
+ )} +
+ )} +
+ ); + }) + )} +
+ + {/* Summary Stats */} +
+
+ + Showing {filteredLogs.length} of {auditLogs.length} log entries + +
+
+
+ ); +} + diff --git a/packages/features/booking-audit/ARCHITECTURE.md b/packages/features/booking-audit/ARCHITECTURE.md index 802d7f07f22e41..4fff9d74a22dea 100644 --- a/packages/features/booking-audit/ARCHITECTURE.md +++ b/packages/features/booking-audit/ARCHITECTURE.md @@ -1,6 +1,8 @@ # 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 (`Actor` and `BookingAudit`) that work together to maintain a complete, immutable audit trail. @@ -25,6 +27,10 @@ model Actor { phone String? name String? + // GDPR/HIPAA Compliance fields + pseudonymizedAt DateTime? // When actor data was pseudonymized for privacy + scheduledDeletionDate DateTime? // When actor record should be fully anonymized after retention period + createdAt DateTime @default(now()) bookingAudits BookingAudit[] @@ -35,6 +41,7 @@ model Actor { @@index([email]) @@index([userId]) @@index([attendeeId]) + @@index([pseudonymizedAt]) // For compliance cleanup jobs } ``` @@ -44,6 +51,7 @@ model Actor { - **Unique Constraints**: Prevents duplicate 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 +- **Pseudonymization on Deletion**: Actor records are pseudonymized (PII nullified) rather than deleted to maintain HIPAA-compliant immutable audit trails. Supports compliance cleanup jobs via `pseudonymizedAt` and `scheduledDeletionDate` indices --- @@ -61,10 +69,18 @@ model BookingAudit { actorId String actor Actor @relation(fields: [actorId], references: [id], onDelete: Restrict) - type BookingAuditType // Database-level change: created/updated/deleted - action BookingAuditAction // Business operation: what happened - timestamp DateTime // When the action occurred (explicitly provided, no default) - data Json? + 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([bookingId]) @@ -75,7 +91,8 @@ model BookingAudit { - **UUID Primary Key**: Enables time-sortable IDs (will migrate to uuid7 when Prisma 7 is available) - **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, ensuring accurate capture of when the business event occurred +- **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 `bookingId` and `actorId` @@ -95,6 +112,7 @@ Defines the type of entity performing an action: ```prisma enum ActorType { 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 @@ -141,23 +159,17 @@ enum BookingAuditAction { 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") - // Cancellation/Rejection/Assignment reasons - CANCELLATION_REASON_UPDATED @map("cancellation_reason_updated") - REJECTION_REASON_UPDATED @map("rejection_reason_updated") - ASSIGNMENT_REASON_UPDATED @map("assignment_reason_updated") - REASSIGNMENT_REASON_UPDATED @map("reassignment_reason_updated") + // Assignment/Reassignment + REASSIGNMENT @map("reassignment") // Meeting details LOCATION_CHANGED @map("location_changed") - MEETING_URL_UPDATED @map("meeting_url_updated") // No-show tracking HOST_NO_SHOW_UPDATED @map("host_no_show_updated") @@ -174,17 +186,16 @@ enum BookingAuditAction { - `CREATED` 2. **Status Changes**: Track booking lifecycle transitions - - `CANCELLED`, `ACCEPTED`, `REJECTED`, `PENDING`, `AWAITING_HOST`, `RESCHEDULED` + - `CANCELLED`, `ACCEPTED`, `REJECTED`, `RESCHEDULED` 3. **Attendee Management**: Track changes to booking participants - `ATTENDEE_ADDED`, `ATTENDEE_REMOVED` -4. **Reason Updates**: Track updates to explanatory text fields - - `CANCELLATION_REASON_UPDATED`, `REJECTION_REASON_UPDATED` - - `ASSIGNMENT_REASON_UPDATED`, `REASSIGNMENT_REASON_UPDATED` +4. **Assignment/Reassignment**: Track booking host assignment changes + - `REASSIGNMENT` 5. **Meeting Details**: Track changes to meeting logistics - - `LOCATION_CHANGED`, `MEETING_URL_UPDATED` + - `LOCATION_CHANGED` 6. **No-Show Tracking**: Track attendance issues - `HOST_NO_SHOW_UPDATED`, `ATTENDEE_NO_SHOW_UPDATED` @@ -194,6 +205,79 @@ enum BookingAuditAction { --- +## 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. @@ -213,52 +297,52 @@ Used when a booking is initially created. Records the complete state at creation **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 (e.g., PENDING → ACCEPTED). Often includes other field changes like calendar references and meeting URLs. +Used when a booking status changes to accepted. ```typescript { - changes?: Array // Tracks status change + any other field changes (e.g., references, metadata.videoCallUrl) + status // { old: "PENDING", new: "ACCEPTED" } } ``` #### CANCELLED ```typescript { - cancellationReason: string + cancellationReason, // { old: null, new: "Client requested" } + cancelledBy, // { old: null, new: "user@example.com" } + status // { old: "ACCEPTED", new: "CANCELLED" } } ``` -**Design Decision:** Does not store meeting time. The booking's start/end times are immutable and available in the Booking table. The audit only stores what changed (the cancellation reason). - #### REJECTED ```typescript { - rejectionReason: string + rejectionReason, // { old: null, new: "Does not meet requirements" } + status // { old: "PENDING", new: "REJECTED" } } ``` -**Design Decision:** Does not store meeting time. Only the rejection reason changes during this action. - #### RESCHEDULED ```typescript { - startTime: string // New start time (ISO 8601) - endTime: string // New end time (ISO 8601) + 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" } } ``` -**Design Decision:** Stores both new start and end times since these are what changed. The old times are available in previous audit records or can be queried from the booking table. - #### RESCHEDULE_REQUESTED ```typescript { - cancellationReason?: string // Optional reason - changes: Array + cancellationReason, // { old: null, new: "Need to reschedule" } + cancelledBy, // { old: null, new: "user@example.com" } + rescheduled? // { old: false, new: true } - optional } ``` @@ -269,55 +353,33 @@ Used when a booking status changes to accepted (e.g., PENDING → ACCEPTED). Oft #### ATTENDEE_ADDED ```typescript { - addedGuests: string[] // Array of guest emails - changes: Array + addedAttendees // { old: null, new: ["email@example.com", ...] } } ``` -#### ATTENDEE_REMOVED -```typescript -{ - changes: Array -} -``` +Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state. ---- - -### Assignment/Reassignment Actions - -#### ASSIGNMENT_REASON_UPDATED +#### ATTENDEE_REMOVED ```typescript { - assignmentMethod: 'manual' | 'round_robin' | 'salesforce' | 'routing_form' | 'crm_ownership' - assignmentDetails: AssignmentDetailsSchema + removedAttendees // { old: null, new: ["email@example.com", ...] } } ``` -#### REASSIGNMENT_REASON_UPDATED -```typescript -{ - reassignmentReason: string - assignmentMethod: 'manual' | 'round_robin' | 'salesforce' | 'routing_form' | 'crm_ownership' - assignmentDetails: AssignmentDetailsSchema - changes: Array -} -``` +Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state. --- -### Reason Update Actions - -#### CANCELLATION_REASON_UPDATED -```typescript -{ - cancellationReason: string -} -``` +### Assignment/Reassignment Actions -#### REJECTION_REASON_UPDATED +#### REASSIGNMENT ```typescript { - rejectionReason: string + 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 } ``` @@ -328,14 +390,7 @@ Used when a booking status changes to accepted (e.g., PENDING → ACCEPTED). Oft #### LOCATION_CHANGED ```typescript { - changes: Array -} -``` - -#### MEETING_URL_UPDATED -```typescript -{ - changes: Array + location // { old: "Zoom", new: "Google Meet" } } ``` @@ -346,14 +401,14 @@ Used when a booking status changes to accepted (e.g., PENDING → ACCEPTED). Oft #### HOST_NO_SHOW_UPDATED ```typescript { - changes: Array + noShowHost // { old: false, new: true } } ``` #### ATTENDEE_NO_SHOW_UPDATED ```typescript { - changes: Array + noShowAttendee // { old: false, new: true } } ``` @@ -368,60 +423,24 @@ Used when a booking status changes to accepted (e.g., PENDING → ACCEPTED). Oft ### Supporting Schemas -#### ChangeSchema +#### Change Tracking Pattern -Tracks field-level changes in audit records. Used to capture what other fields changed alongside the primary action. +**All changes use the `{ old, new }` pattern:** +Each field tracks both old and new values: ```typescript -{ - field: string // Name of the field that changed - oldValue: unknown // Value before change (optional for creation) - newValue: unknown // Value after change (optional for deletion) +fieldName: { + old: T | null, // Previous value (null if field didn't exist) + new: T // New value } ``` -**Purpose:** -The `changes` array captures additional field modifications that occur during an action. For example: -- **ACCEPTED**: Tracks status change + calendar references + meeting URL updates -- **LOCATION_CHANGED**: Tracks old and new location values -- **ATTENDEE_ADDED**: Tracks which attendee fields were modified - -**Usage:** -- Field creation: Only `field` and `newValue` present -- Field update: All three fields present -- Field deletion: Only `field` and `oldValue` present - ---- - -#### AssignmentDetailsSchema - -Tracks assignment/reassignment context for round-robin and manual assignment: - -```typescript -{ - // IDs for querying - teamId: number (optional) - teamName: string (optional) - - // User details (historical snapshot) - assignedUser: { - id: number - name: string - email: string - } - - previousUser: { // Optional: first assignment has no previous user - id: number - name: string - email: string - } (optional) -} -``` - -**Purpose:** -- Maintains historical snapshot of user information for display -- Stores team context for team-based assignments -- Tracks reassignment history (previous → current user) +**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 --- @@ -518,42 +537,76 @@ Different actions have different data requirements: - `CANCELLED` needs `cancellationReason` - `RESCHEDULED` needs new `startTime` and `endTime` - `ATTENDEE_ADDED` needs `attendee` information -- `REASSIGNMENT_REASON_UPDATED` needs assignment context +- `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 Helper Service that defines: -1. **Schema Definition**: Zod schema specifying required/optional fields -2. **Schema Version**: Tracked per-action type (e.g., `CANCELLED_v1`, `RESCHEDULED_v1`) -3. **Validation**: Type-safe validation of audit data -4. **Display Logic**: How to render the audit record in the UI +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 -**Example Action Helper Services:** -- `CreatedAuditActionHelperService` → Handles `CREATED` action -- `CancelledAuditActionHelperService` → Handles `CANCELLED` action -- `RescheduledAuditActionHelperService` → Handles `RESCHEDULED` action -- `ReassignmentAuditActionHelperService` → Handles `REASSIGNMENT_REASON_UPDATED` action +**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 -- **Easier Migration**: Only need to migrate records for the specific action that changed +- **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 a strongly-typed schema +- **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 -### Version Tracking -Versions are tracked in the Action Helper Service classes: -```typescript -// Example: When CANCELLED schema changes -CancelledAuditActionHelperService.schema // v1 -// Later, when adding a new field: -CancelledAuditActionHelperService.schema // v2 -// Other actions remain at their current version -``` +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 --- @@ -565,11 +618,11 @@ Audit records are append-only. Once created, they are never modified or deleted. - Tamper-proof audit trail - Compliance with audit requirements -### 2. Historical Preservation +### 2. Historical Preservation & Pseudonymization Actor information is preserved even after source records are deleted: -- User deletion doesn't remove Actor records -- Actor table maintains snapshot of user/guest information -- Audit trail remains complete and queryable +- User deletion doesn't remove Actor records - they are pseudonymized instead +- Actor table maintains pseudonymized snapshot of user/guest information without PII +- Audit trail remains complete, queryable, and compliant with HIPAA/GDPR requirements ### 3. Flexibility The JSON `data` field provides schema flexibility: @@ -629,56 +682,29 @@ await auditService.onBookingCreated(bookingId, userId, { --- -## Common Query Patterns - -### Get All Audits for a Booking - -```typescript -const audits = await prisma.bookingAudit.findMany({ - where: { bookingId: "booking-uuid" }, - include: { actor: true }, - orderBy: { timestamp: 'asc' } -}); -``` - -### Get All Actions by a User - -```typescript -const actor = await prisma.actor.findUnique({ - where: { userId: 123 } -}); - -const audits = await prisma.bookingAudit.findMany({ - where: { actorId: actor.id }, - orderBy: { timestamp: 'desc' } -}); -``` +## 7. Compliance & Data Privacy -### Get All Cancellations +**GDPR & HIPAA Compliance:** +- **Actor records are NOT deleted on user deletion** - Instead, Actor records are pseudonymized (email/phone/name 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 Actor table design ensures BookingAudit records are never modified, only the referenced Actor record is pseudonymized +- **GDPR Article 17 compliance** is achieved through pseudonymization: When a user/guest requests deletion, set `userId=null`, `email=null`, `phone=null`, `name=null` on the Actor record, and store `pseudonymizedAt` timestamp + `scheduledDeletionDate` for compliance tracking +- **Retention Policy**: Actor records should be fully anonymized after 6-7 years per legal requirements +**Implementation Pattern:** ```typescript -const cancellations = await prisma.bookingAudit.findMany({ - where: { - action: 'CANCELLED', - type: 'RECORD_UPDATED' - }, - include: { actor: true } -}); -``` - -### Get Audit Trail with Field Changes - -```typescript -const audits = await prisma.bookingAudit.findMany({ - where: { bookingId: "booking-uuid" }, - select: { - timestamp: true, - action: true, - actor: { select: { name: true, email: true, type: true } }, - data: true // Contains 'changes' array - }, - orderBy: { timestamp: 'asc' } +// On user deletion: Pseudonymize, don't delete +await prisma.actor.update({ + where: { userId: deletedUserId }, + data: { + userId: 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 ``` --- @@ -708,10 +734,10 @@ The audit system is accessed through `BookingAuditService`, which provides: - `onAttendeeAdded()` - Track attendee addition - `onAttendeeRemoved()` - Track attendee removal - `onLocationChanged()` - Track location changes -- `onMeetingUrlUpdated()` - Track meeting URL updates - `onHostNoShowUpdated()` - Track host no-show - `onAttendeeNoShowUpdated()` - Track attendee no-show -- And more... +- `onReassignment()` - Track booking reassignment +- `onRescheduleRequested()` - Track reschedule requests ### Actor Management @@ -719,6 +745,38 @@ The audit system is accessed through `BookingAuditService`, which provides: - Automatic Actor 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, userId, 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 @@ -726,12 +784,12 @@ The audit system is accessed through `BookingAuditService`, which provides: 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 +- ✅ **Historical Preservation**: Data retained even after deletions through pseudonymization - ✅ **Flexible Schema**: JSON data supports evolution without migrations - ✅ **Strong Integrity**: Database constraints ensure data quality - ✅ **Performance**: Strategic indexes for common query patterns -- ✅ **Compliance Ready**: Immutable, traceable audit records +- ✅ **HIPAA & GDPR Compliant**: Immutable audit records, pseudonymized actors, compliance-ready - ✅ **Reality-Based Recording**: Captures actual state, aiding in debugging and analysis -This architecture supports compliance requirements, debugging, analytics, and provides transparency for both users and administrators. +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/FOUNDATION-AND-INTEGRATION-PLAN.md b/packages/features/booking-audit/FOUNDATION-AND-INTEGRATION-PLAN.md deleted file mode 100644 index 6d09cbdebb9df5..00000000000000 --- a/packages/features/booking-audit/FOUNDATION-AND-INTEGRATION-PLAN.md +++ /dev/null @@ -1,541 +0,0 @@ -# Booking Audit System - Foundation Branch & Integration Plan - -## Overview - -This document describes the foundation branch for the Booking Audit System and provides a structured plan for integrating it into the codebase through small, stackable PRs. - -The foundation branch (`refactor-audit-abstraction-k7vXx`) contains the complete skeleton of the audit system without any integration into existing booking flows. This allows for: -- Independent testing of the audit infrastructure -- Gradual, reviewable integration through small PRs -- Clear separation between infrastructure and integration concerns - ---- - -## Foundation Branch Contents - -### Commit: `c53b31ff17` -**Message:** feat: Booking Audit System foundation (skeleton without integration) - -### What's Included - -#### 1. Database Schema (`packages/prisma/schema.prisma`) - -**Models:** -- `Actor` - Tracks entities that perform actions on bookings - - Fields: id, type, userId, attendeeId, email, phone, name, createdAt - - Unique constraints on userId, attendeeId, email, phone - - Indexes on email, userId, attendeeId - -- `BookingAudit` - Stores audit records for booking actions - - Fields: id, bookingId, actorId, type, action, timestamp, createdAt, updatedAt, data (JSON) - - Foreign key to Actor with onDelete: Restrict - - Indexes on bookingId, actorId - -**Enums:** -- `ActorType` - USER, GUEST, ATTENDEE, SYSTEM -- `BookingAuditType` - RECORD_CREATED, RECORD_UPDATED, RECORD_DELETED -- `BookingAuditAction` - 18 actions covering booking lifecycle: - - Lifecycle: CREATED - - Status Changes: CANCELLED, ACCEPTED, REJECTED, PENDING, AWAITING_HOST, RESCHEDULED - - Attendee Management: ATTENDEE_ADDED, ATTENDEE_REMOVED - - Reason Updates: CANCELLATION_REASON_UPDATED, REJECTION_REASON_UPDATED, ASSIGNMENT_REASON_UPDATED, REASSIGNMENT_REASON_UPDATED - - Meeting Details: LOCATION_CHANGED, MEETING_URL_UPDATED - - No-Show Tracking: HOST_NO_SHOW_UPDATED, ATTENDEE_NO_SHOW_UPDATED - - Rescheduling: RESCHEDULE_REQUESTED - -#### 2. Repository Layer (`packages/features/booking-audit/lib/repository/`) - -**Interfaces:** -- `IActorRepository` - Actor operations (findByUserId, upsertUserActor, getSystemActor) -- `IBookingAuditRepository` - Audit operations (create) - -**Implementations:** -- `PrismaActorRepository` - Prisma-based actor repository -- `PrismaBookingAuditRepository` - Prisma-based audit repository with validation - -#### 3. Service Layer (`packages/features/booking-audit/lib/service/`) - -**BookingAuditService:** -- Main service class with dependency injection -- Constants: SYSTEM_ACTOR_ID, CURRENT_AUDIT_DATA_VERSION -- Private methods: getOrCreateUserActor(), createAuditRecord() -- Public audit methods (18 total): - - `onBookingCreated()` - - `onBookingAccepted()` - - `onBookingRejected()` - - `onBookingPending()` - - `onBookingAwaitingHost()` - - `onBookingCancelled()` - - `onBookingRescheduled()` - - `onRescheduleRequested()` - - `onAttendeeAdded()` - - `onAttendeeRemoved()` - - `onCancellationReasonUpdated()` - - `onRejectionReasonUpdated()` - - `onAssignmentReasonUpdated()` - - `onReassignmentReasonUpdated()` - - `onLocationChanged()` - - `onMeetingUrlUpdated()` - - `onHostNoShowUpdated()` - - `onAttendeeNoShowUpdated()` - - `onSystemAction()` (generic method for automated actions) -- Display methods: - - `getDisplaySummary()` - Human-readable summary - - `getDisplayDetails()` - Detailed key-value pairs for UI - -#### 4. Action Services (`packages/features/booking-audit/lib/actions/`) - -Each action has a dedicated service class with: -- Static Zod schema for validation -- `parse()` method for data validation -- `getDisplaySummary()` method for i18n-aware display -- `getDisplayDetails()` method for detailed UI display -- Exported TypeScript type - -**Action Services:** -- `CreatedAuditActionService` -- `CancelledAuditActionService` -- `RejectedAuditActionService` -- `RescheduledAuditActionService` -- `RescheduleRequestedAuditActionService` -- `AttendeeAddedAuditActionService` -- `AttendeeRemovedAuditActionService` -- `AssignmentAuditActionService` -- `ReassignmentAuditActionService` -- `CancellationReasonUpdatedAuditActionService` -- `RejectionReasonUpdatedAuditActionService` -- `LocationChangedAuditActionService` -- `MeetingUrlUpdatedAuditActionService` -- `HostNoShowUpdatedAuditActionService` -- `AttendeeNoShowUpdatedAuditActionService` -- `StatusChangeAuditActionService` - -#### 5. Common Schemas (`packages/features/booking-audit/lib/common/schemas.ts`) - -- `ChangeSchema` - Tracks field-level changes (field, oldValue, newValue) -- `AssignmentDetailsSchema` - Assignment context (teamId, teamName, assignedUser, previousUser) - -#### 6. Type Definitions (`packages/features/booking-audit/lib/types/index.ts`) - -- Union schema for all audit data types -- Re-exports of all Action Service types -- Comprehensive TypeScript types for type safety - -#### 7. Documentation - -- `ARCHITECTURE.md` - Complete database architecture documentation -- `FOUNDATION-AND-INTEGRATION-PLAN.md` - This file - ---- - -## What's NOT Included (By Design) - -The foundation branch intentionally **excludes integration code** to keep it focused and reviewable. The following files remain unchanged from their pre-audit state: - -- `packages/features/bookings/lib/service/RegularBookingService.ts` -- `packages/features/bookings/lib/handleConfirmation.ts` -- `packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts` -- `packages/features/ee/round-robin/roundRobinManualReassignment.ts` -- `packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts` -- `packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts` -- Any other booking-related files - ---- - -## Integration Strategy: Stackable PRs - -The audit system will be integrated through small, focused PRs that stack on top of the foundation branch. Each PR should: -1. Be independently reviewable -2. Focus on a single booking operation or feature area -3. Include tests for the integration -4. Be small enough to review in 15-30 minutes - -### Suggested PR Sequence - -#### Phase 1: Core Booking Operations - -**PR 1: Booking Creation Audit** -- **Files to modify:** `RegularBookingService.ts` -- **Scope:** Add audit call in booking creation flow -- **Action:** `onBookingCreated()` -- **Data Required:** startTime, endTime, status - -**PR 2: Booking Cancellation Audit** -- **Files to modify:** `handleCancelBooking.ts`, cancellation handlers -- **Scope:** Add audit call in cancellation flow -- **Action:** `onBookingCancelled()` -- **Data Required:** cancellationReason - -**PR 3: Booking Confirmation/Acceptance Audit** -- **Files to modify:** `handleConfirmation.ts` -- **Scope:** Add audit call when booking is accepted -- **Action:** `onBookingAccepted()` -- **Data Required:** changes (optional) - -**PR 4: Booking Rejection Audit** -- **Files to modify:** Rejection handlers -- **Scope:** Add audit call when booking is rejected -- **Action:** `onBookingRejected()` -- **Data Required:** rejectionReason - -**PR 5: Booking Rescheduling Audit** -- **Files to modify:** Reschedule handlers -- **Scope:** Add audit call when booking is rescheduled -- **Actions:** `onBookingRescheduled()`, `onRescheduleRequested()` -- **Data Required:** new startTime, endTime - -#### Phase 2: Attendee Management - -**PR 6: Guest Addition Audit** -- **Files to modify:** `addGuests.handler.ts` -- **Scope:** Add audit call when guests are added -- **Action:** `onAttendeeAdded()` -- **Data Required:** addedGuests array, changes - -**PR 7: Guest Removal Audit** -- **Files to modify:** Guest removal handlers -- **Scope:** Add audit call when guests are removed -- **Action:** `onAttendeeRemoved()` -- **Data Required:** changes - -#### Phase 3: Round-Robin & Assignment - -**PR 8: Manual Reassignment Audit** -- **Files to modify:** `roundRobinManualReassignment.ts` -- **Scope:** Add audit call when bookings are manually reassigned -- **Action:** `onReassignmentReasonUpdated()` -- **Data Required:** reassignmentReason, assignmentMethod, assignmentDetails - -**PR 9: Automatic Assignment Audit** -- **Files to modify:** Round-robin assignment logic -- **Scope:** Add audit call for automatic assignments -- **Action:** `onAssignmentReasonUpdated()` -- **Data Required:** assignmentMethod, assignmentDetails - -#### Phase 4: Meeting Details - -**PR 10: Location Change Audit** -- **Files to modify:** Location update handlers -- **Scope:** Add audit call when meeting location changes -- **Action:** `onLocationChanged()` -- **Data Required:** changes - -**PR 11: Meeting URL Update Audit** -- **Files to modify:** Meeting URL update handlers -- **Scope:** Add audit call when meeting URL is updated -- **Action:** `onMeetingUrlUpdated()` -- **Data Required:** changes - -#### Phase 5: No-Show Tracking - -**PR 12: Host No-Show Audit** -- **Files to modify:** No-show tracking handlers -- **Scope:** Add audit call when host no-show is recorded -- **Action:** `onHostNoShowUpdated()` -- **Data Required:** changes - -**PR 13: Attendee No-Show Audit** -- **Files to modify:** No-show tracking handlers -- **Scope:** Add audit call when attendee no-show is recorded -- **Action:** `onAttendeeNoShowUpdated()` -- **Data Required:** changes - -#### Phase 6: Reason Updates - -**PR 14: Cancellation Reason Update Audit** -- **Files to modify:** Cancellation reason update handlers -- **Scope:** Add audit call when cancellation reason is modified -- **Action:** `onCancellationReasonUpdated()` -- **Data Required:** cancellationReason - -**PR 15: Rejection Reason Update Audit** -- **Files to modify:** Rejection reason update handlers -- **Scope:** Add audit call when rejection reason is modified -- **Action:** `onRejectionReasonUpdated()` -- **Data Required:** rejectionReason - ---- - -## PR Template - -Use this template for each integration PR: - -### PR Title -``` -feat(audit): Add audit logging for [specific operation] -``` - -### PR Description -``` -## Overview -Integrates audit logging for [specific operation] as part of the Booking Audit System rollout. - -## Changes -- Modified: [list files] -- Added audit call: `BookingAuditService.[methodName]()` -- Action tracked: [action name] - -## Integration Point -[Brief description of where in the flow the audit is called] - -## Data Captured -[List the fields captured in the audit data] - -## Testing -- [ ] Manual testing of [operation] -- [ ] Verified audit record created in database -- [ ] Verified audit data matches schema -- [ ] Type-check passes -- [ ] Linter passes - -## Stacked On -- Foundation Branch: `refactor-audit-abstraction-k7vXx` (commit: c53b31ff17) -- Previous PR: [if applicable] - -## Next PR -[What integration comes next] -``` - ---- - -## Development Guidelines - -### When Adding Audit Calls - -1. **Identify the business event** - - What action occurred? (created, cancelled, rescheduled, etc.) - - Who performed it? (userId for user actions, undefined for system) - - When did it happen? (use current timestamp via BookingAuditService) - -2. **Determine the actor** - ```typescript - // For user actions - const userId = currentUser.id; - - // For system actions - const userId = undefined; // Service will use SYSTEM_ACTOR_ID - ``` - -3. **Prepare the audit data** - ```typescript - import type { [ActionName]AuditData } from "@calcom/features/booking-audit/lib/types"; - - const auditData: [ActionName]AuditData = { - // Follow the schema defined in the corresponding Action Service - }; - ``` - -4. **Call the audit service** - ```typescript - import { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService"; - - const bookingAuditService = BookingAuditService.create(); - await bookingAuditService.on[ActionName]( - bookingId, - userId, - auditData - ); - ``` - -5. **Handle errors gracefully** - ```typescript - try { - await bookingAuditService.on[ActionName](...); - } catch (error) { - // Log but don't fail the main operation - logger.error("Failed to create audit record", error); - } - ``` - -### Testing Integration - -For each PR, test: -1. **Audit record creation** - - Verify record appears in `BookingAudit` table - - Check `actorId` links to correct `Actor` - - Verify `data` field contains expected JSON - -2. **Data validation** - - Zod schema validation passes - - Required fields are present - - Optional fields handled correctly - -3. **Type safety** - - TypeScript compilation succeeds - - No `any` types introduced - - Proper type imports used - -4. **Error handling** - - Audit failures don't break main flow - - Errors are logged appropriately - ---- - -## Common Patterns - -### Pattern 1: User-Initiated Action -```typescript -const bookingAuditService = BookingAuditService.create(); - -await bookingAuditService.onBookingCancelled( - booking.id, - req.userId, // Current user performing the action - { - cancellationReason: input.cancellationReason, - } -); -``` - -### Pattern 2: System-Initiated Action -```typescript -const bookingAuditService = BookingAuditService.create(); - -await bookingAuditService.onBookingCreated( - booking.id, - undefined, // System action - { - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - status: booking.status, - } -); -``` - -### Pattern 3: With Change Tracking -```typescript -import type { StatusChangeAuditData } from "@calcom/features/booking-audit/lib/types"; - -const changes = [ - { field: "status", oldValue: "PENDING", newValue: "ACCEPTED" }, - { field: "references", oldValue: null, newValue: references }, -]; - -const auditData: StatusChangeAuditData = { changes }; - -await bookingAuditService.onBookingAccepted( - booking.id, - req.userId, - auditData -); -``` - -### Pattern 4: With Assignment Context -```typescript -import type { ReassignmentAuditData } from "@calcom/features/booking-audit/lib/types"; - -const auditData: ReassignmentAuditData = { - reassignmentReason: "Manual reassignment by admin", - assignmentMethod: "manual", - assignmentDetails: { - teamId: team.id, - teamName: team.name, - assignedUser: { - id: newHost.id, - name: newHost.name, - email: newHost.email, - }, - previousUser: { - id: oldHost.id, - name: oldHost.name, - email: oldHost.email, - }, - }, - changes: [ - { field: "userId", oldValue: oldHost.id, newValue: newHost.id }, - ], -}; - -await bookingAuditService.onReassignmentReasonUpdated( - booking.id, - req.userId, - auditData -); -``` - ---- - -## Troubleshooting - -### Issue: "Actor not found" -**Cause:** User was deleted but Actor record doesn't exist -**Solution:** Actor records are created on-demand via `upsertUserActor()` - -### Issue: "Zod validation failed" -**Cause:** Audit data doesn't match the schema -**Solution:** Check the Action Service schema definition and ensure all required fields are present - -### Issue: "Cannot delete Actor" -**Cause:** Attempting to delete Actor with existing BookingAudit records -**Solution:** This is intentional (onDelete: Restrict). Actors should never be deleted. - -### Issue: "Type errors in integration code" -**Cause:** Incorrect type imports or data structure -**Solution:** Import types from `@calcom/features/booking-audit/lib/types` and follow the schemas - ---- - -## Branch Management - -### Main Branches -- **Foundation:** `refactor-audit-abstraction-k7vXx` (commit: c53b31ff17) -- **Base for PRs:** All integration PRs should branch from the foundation branch - -### Workflow -```bash -# Create a new integration PR branch -git checkout refactor-audit-abstraction-k7vXx -git pull origin refactor-audit-abstraction-k7vXx -git checkout -b feat/audit-booking-creation - -# Make changes, commit, and push -git add . -git commit -m "feat(audit): Add audit logging for booking creation" -git push origin feat/audit-booking-creation - -# Create PR targeting the foundation branch -# After merge, next PR branches from updated foundation branch -``` - -### Stacking PRs -1. PR 1 targets foundation branch -2. After PR 1 merges, foundation branch is updated -3. PR 2 branches from updated foundation -4. Repeat... - ---- - -## Success Criteria - -Each integration PR should satisfy: -- ✅ Audit record created successfully -- ✅ Correct actor identified -- ✅ Data validated by Zod schema -- ✅ Type-check passes -- ✅ Linter passes -- ✅ Tests pass (if applicable) -- ✅ No breaking changes to existing functionality -- ✅ Error handling doesn't break main flow -- ✅ Code review completed - ---- - -## References - -- **Architecture Documentation:** `ARCHITECTURE.md` - Complete database and design documentation -- **Foundation Commit:** `c53b31ff17` - Initial skeleton implementation -- **PR Tracking:** Track progress through GitHub PRs targeting the foundation branch - ---- - -## Maintenance - -This document should be updated when: -- New audit actions are added -- Integration patterns change -- PR sequence needs adjustment -- New testing requirements emerge - -**Last Updated:** 2025-11-01 -**Foundation Commit:** c53b31ff17 -**Status:** Foundation complete, integration PRs pending - diff --git a/packages/features/booking-audit/di/ActorRepository.module.ts b/packages/features/booking-audit/di/ActorRepository.module.ts new file mode 100644 index 00000000000000..949d4d0f1dde3c --- /dev/null +++ b/packages/features/booking-audit/di/ActorRepository.module.ts @@ -0,0 +1,17 @@ +import { PrismaActorRepository } from "@calcom/features/booking-audit/lib/repository/PrismaActorRepository"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; + +import { type Container, createModule } from "../../di/di"; + +export const actorRepositoryModule = createModule(); +const token = BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY_MODULE; +actorRepositoryModule.bind(token).toClass(PrismaActorRepository, [DI_TOKENS.PRISMA_CLIENT]); + +export const moduleLoader = { + token, + loadModule: function (container: Container) { + container.load(moduleToken, actorRepositoryModule); + }, +}; diff --git a/packages/features/booking-audit/di/BookingAuditRepository.module.ts b/packages/features/booking-audit/di/BookingAuditRepository.module.ts new file mode 100644 index 00000000000000..8bc3f89b8da8d3 --- /dev/null +++ b/packages/features/booking-audit/di/BookingAuditRepository.module.ts @@ -0,0 +1,17 @@ +import { PrismaBookingAuditRepository } from "@calcom/features/booking-audit/lib/repository/PrismaBookingAuditRepository"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; + +import { type Container, createModule } from "../../di/di"; + +export const bookingAuditRepositoryModule = createModule(); +const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_REPOSITORY; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_REPOSITORY_MODULE; +bookingAuditRepositoryModule.bind(token).toClass(PrismaBookingAuditRepository, [DI_TOKENS.PRISMA_CLIENT]); + +export const moduleLoader = { + token, + loadModule: function (container: Container) { + container.load(moduleToken, bookingAuditRepositoryModule); + }, +}; diff --git a/packages/features/booking-audit/di/BookingAuditService.module.ts b/packages/features/booking-audit/di/BookingAuditService.module.ts new file mode 100644 index 00000000000000..e1ddd0b5b1c507 --- /dev/null +++ b/packages/features/booking-audit/di/BookingAuditService.module.ts @@ -0,0 +1,26 @@ +import { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module"; +import { moduleLoader as actorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/ActorRepository.module"; + +import { type Container, createModule } from "../../di/di"; + +export const bookingAuditServiceModule = createModule(); +const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_SERVICE; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_SERVICE_MODULE; +bookingAuditServiceModule.bind(token).toClass(BookingAuditService, { + bookingAuditRepository: BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_REPOSITORY, + actorRepository: BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY, +}); + +export const moduleLoader = { + token, + loadModule: function (container: Container) { + // Load repository modules first + bookingAuditRepositoryModuleLoader.loadModule(container); + actorRepositoryModuleLoader.loadModule(container); + container.load(moduleToken, bookingAuditServiceModule); + }, +}; + +export type { BookingAuditService }; diff --git a/packages/features/booking-audit/di/tokens.ts b/packages/features/booking-audit/di/tokens.ts new file mode 100644 index 00000000000000..4a93906a1844c2 --- /dev/null +++ b/packages/features/booking-audit/di/tokens.ts @@ -0,0 +1,8 @@ +export const BOOKING_AUDIT_DI_TOKENS = { + BOOKING_AUDIT_SERVICE: Symbol("BookingAuditService"), + BOOKING_AUDIT_SERVICE_MODULE: Symbol("BookingAuditServiceModule"), + BOOKING_AUDIT_REPOSITORY: Symbol("BookingAuditRepository"), + BOOKING_AUDIT_REPOSITORY_MODULE: Symbol("BookingAuditRepositoryModule"), + ACTOR_REPOSITORY: Symbol("ActorRepository"), + ACTOR_REPOSITORY_MODULE: Symbol("ActorRepositoryModule"), +}; diff --git a/packages/features/booking-audit/lib/actions/AssignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/AssignmentAuditActionService.ts deleted file mode 100644 index ddee363f53a2c8..00000000000000 --- a/packages/features/booking-audit/lib/actions/AssignmentAuditActionService.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from "zod"; -import type { TFunction } from "next-i18next"; - -import { AssignmentDetailsSchema } from "../common/schemas"; - -/** - * Assignment Audit Action Service - * Handles ASSIGNMENT_REASON_UPDATED action - */ -export class AssignmentAuditActionService { - static readonly schema = z.object({ - assignmentMethod: z.enum(['manual', 'round_robin', 'salesforce', 'routing_form', 'crm_ownership']), - assignmentDetails: AssignmentDetailsSchema, - }); - - parse(data: unknown): z.infer { - return AssignmentAuditActionService.schema.parse(data); - } - - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.assigned_user', { userName: data.assignmentDetails.assignedUser.name }); - } - - getDisplayDetails(data: z.infer, t: TFunction): Record { - return { - 'Type': data.assignmentMethod, - 'Assigned To': data.assignmentDetails.assignedUser.name, - 'Team': data.assignmentDetails.teamName || '-', - }; - } -} - -export type AssignmentAuditData = z.infer; - diff --git a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts index ca5d9ea3afb1cd..1f7a0dac4db005 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts @@ -1,33 +1,60 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; -import { ChangeSchema } from "../common/schemas"; +import { StringArrayChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; /** * Attendee Added Audit Action Service - * Handles ATTENDEE_ADDED action + * Handles ATTENDEE_ADDED action with per-action versioning + * + * Version History: + * - v1: Initial schema with addedAttendees */ -export class AttendeeAddedAuditActionService { - static readonly schema = z.object({ - addedGuests: z.array(z.string()), - changes: z.array(ChangeSchema), + +const attendeeAddedFieldsSchemaV1 = z.object({ + addedAttendees: StringArrayChangeSchema, +}); + +export class AttendeeAddedAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = attendeeAddedFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return AttendeeAddedAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: attendeeAddedFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); } - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.added_guests', { count: data.addedGuests.length }); + parseStored(data: unknown) { + return this.helper.parseStored(data); } - getDisplayDetails(data: z.infer, t: TFunction): Record { + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { + const { fields } = storedData; + return t('audit.added_guests', { count: fields.addedAttendees.new.length }); + } + + getDisplayDetails(storedData: { version: number; fields: z.infer }, _t: TFunction): Record { + const { fields } = storedData; return { - 'Added Guests': data.addedGuests.join(', '), - 'Count': data.addedGuests.length.toString(), + 'Added Guests': fields.addedAttendees.new.join(', '), + 'Count': fields.addedAttendees.new.length.toString(), }; } } -export type AttendeeAddedAuditData = z.infer; - +export type AttendeeAddedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts index 41a3f3829c5ff0..9ca453df309617 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts @@ -1,29 +1,58 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; -import { ChangeSchema } from "../common/schemas"; +import { BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; /** * Attendee No-Show Updated Audit Action Service - * Handles ATTENDEE_NO_SHOW_UPDATED action + * Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning + * + * Version History: + * - v1: Initial schema with noShowAttendee */ -export class AttendeeNoShowUpdatedAuditActionService { - static readonly schema = z.object({ - changes: z.array(ChangeSchema), + +const attendeeNoShowUpdatedFieldsSchemaV1 = z.object({ + noShowAttendee: BooleanChangeSchema, +}); + +export class AttendeeNoShowUpdatedAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = attendeeNoShowUpdatedFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return AttendeeNoShowUpdatedAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: attendeeNoShowUpdatedFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); } - getDisplaySummary(data: z.infer, t: TFunction): string { + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { return t('audit.attendee_no_show_updated'); } - getDisplayDetails(data: z.infer, t: TFunction): Record { - return {}; + getDisplayDetails(storedData: { version: number; fields: z.infer }, t: TFunction): Record { + const { fields } = storedData; + return { + 'Attendee No-Show': `${fields.noShowAttendee.old ?? false} → ${fields.noShowAttendee.new}`, + }; } } -export type AttendeeNoShowUpdatedAuditData = z.infer; - +export type AttendeeNoShowUpdatedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts index 8b85ec6dd2d13f..cae30fbcd1e96d 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts @@ -1,29 +1,60 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; -import { ChangeSchema } from "../common/schemas"; +import { StringArrayChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; /** * Attendee Removed Audit Action Service - * Handles ATTENDEE_REMOVED action + * Handles ATTENDEE_REMOVED action with per-action versioning + * + * Version History: + * - v1: Initial schema with removedAttendees */ -export class AttendeeRemovedAuditActionService { - static readonly schema = z.object({ - changes: z.array(ChangeSchema), + +const attendeeRemovedFieldsSchemaV1 = z.object({ + removedAttendees: StringArrayChangeSchema, +}); + +export class AttendeeRemovedAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = attendeeRemovedFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return AttendeeRemovedAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: attendeeRemovedFieldsSchemaV1, version: this.VERSION }); } - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.attendee_removed'); + parseFields(input: unknown) { + return this.helper.parseFields(input); } - getDisplayDetails(data: z.infer, t: TFunction): Record { - return {}; + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { + const { fields } = storedData; + return t('audit.removed_guests', { count: fields.removedAttendees.new.length }); } -} -export type AttendeeRemovedAuditData = z.infer; + getDisplayDetails(storedData: { version: number; fields: z.infer }, _t: TFunction): Record { + const { fields } = storedData; + return { + 'Removed Guests': fields.removedAttendees.new.join(', '), + 'Count': fields.removedAttendees.new.length.toString(), + }; + } +} +export type AttendeeRemovedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts b/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts new file mode 100644 index 00000000000000..a8b951c7c84bd6 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +/** + * Audit Action Service Helper + * + * Provides reusable utility methods for audit action services via composition. + * + * We use composition instead of inheritance because: + * - Services can evolve to v2, v3 independently without polluting a shared base class + * - Easier to test (inject mock helper) + * - Looser coupling - services explicitly choose what to delegate + */ +export class AuditActionServiceHelper { + private readonly fieldsSchema: TFieldsSchema; + private readonly version: number; + private readonly dataSchema: z.ZodObject<{ version: z.ZodLiteral; fields: TFieldsSchema }>; + + constructor({ fieldsSchema, version }: { fieldsSchema: TFieldsSchema, version: number }) { + this.fieldsSchema = fieldsSchema; + this.version = version + this.dataSchema = z.object({ + version: z.literal(this.version), + fields: this.fieldsSchema, + }); + } + /** + * Parse input fields and wrap with version for writing to database + */ + parseFields(input: unknown): z.infer { + const parsed = this.fieldsSchema.parse(input); + return { + version: this.version, + fields: parsed, + }; + } + + /** + * Parse stored audit record (includes version wrapper) + */ + parseStored(data: unknown): z.infer { + return this.dataSchema.parse(data); + } + + /** + * Extract version from stored data + */ + getVersion(data: unknown): number { + const parsed = z.object({ version: z.number() }).parse(data); + return parsed.version; + } +} + diff --git a/packages/features/booking-audit/lib/actions/CancellationReasonUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CancellationReasonUpdatedAuditActionService.ts deleted file mode 100644 index e0b6d00634b98e..00000000000000 --- a/packages/features/booking-audit/lib/actions/CancellationReasonUpdatedAuditActionService.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from "zod"; -import type { TFunction } from "next-i18next"; - -/** - * Cancellation Reason Updated Audit Action Service - * Handles CANCELLATION_REASON_UPDATED action - */ -export class CancellationReasonUpdatedAuditActionService { - static readonly schema = z.object({ - cancellationReason: z.string(), - }); - - parse(data: unknown): z.infer { - return CancellationReasonUpdatedAuditActionService.schema.parse(data); - } - - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.cancellation_reason_updated'); - } - - getDisplayDetails(data: z.infer, t: TFunction): Record { - return { - 'Reason': data.cancellationReason, - }; - } -} - -export type CancellationReasonUpdatedAuditData = z.infer; - diff --git a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts index 09588655465b8a..fdd7b911cbf13a 100644 --- a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts @@ -1,29 +1,62 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; + /** * Cancelled Audit Action Service - * Handles CANCELLED action + * Handles CANCELLED action with per-action versioning + * + * Version History: + * - v1: Initial schema with cancellationReason, cancelledBy, status */ -export class CancelledAuditActionService { - static readonly schema = z.object({ - cancellationReason: z.string(), + +const cancelledFieldsSchemaV1 = z.object({ + cancellationReason: StringChangeSchema, + cancelledBy: StringChangeSchema, + status: StringChangeSchema, +}); + +export class CancelledAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = cancelledFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return CancelledAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: cancelledFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); } - getDisplaySummary(data: z.infer, t: TFunction): string { + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown) { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { return t('audit.cancelled_booking'); } - getDisplayDetails(data: z.infer, t: TFunction): Record { + getDisplayDetails(storedData: { version: number; fields: z.infer }, t: TFunction): Record { + const { fields } = storedData; return { - 'Reason': data.cancellationReason, + 'Cancellation Reason': fields.cancellationReason.new ?? '-', + 'Previous Reason': fields.cancellationReason.old ?? '-', + 'Cancelled By': `${fields.cancelledBy.old ?? '-'} → ${fields.cancelledBy.new ?? '-'}`, }; } } -export type CancelledAuditData = z.infer; - +export type CancelledAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index 8b8fe66f77a929..5965d7a55f027e 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -3,33 +3,63 @@ import type { TFunction } from "next-i18next"; import dayjs from "@calcom/dayjs"; import { BookingStatus } from "@calcom/prisma/enums"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; + /** * Created Audit Action Service - * Handles RECORD_CREATED action + * Handles RECORD_CREATED action with per-action versioning + * + * Note: CREATED action captures initial state, so it doesn't use { old, new } pattern + * + * Version History: + * - v1: Initial schema with startTime, endTime, status */ -export class CreatedAuditActionService { - static readonly schema = z.object({ - startTime: z.string(), - endTime: z.string(), - status: z.nativeEnum(BookingStatus), + +const createdFieldsSchemaV1 = z.object({ + startTime: z.string(), + endTime: z.string(), + status: z.nativeEnum(BookingStatus), +}); + +export class CreatedAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = createdFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return CreatedAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: createdFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); } - getDisplaySummary(data: z.infer, t: TFunction): string { + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { return t('audit.booking_created'); } - getDisplayDetails(data: z.infer, t: TFunction): Record { + getDisplayDetails(storedData: { version: number; fields: z.infer }, t: TFunction): Record { + const { fields } = storedData; return { - 'Start Time': dayjs(data.startTime).format('MMM D, YYYY h:mm A'), - 'End Time': dayjs(data.endTime).format('MMM D, YYYY h:mm A'), - 'Initial Status': data.status, + 'Start Time': dayjs(fields.startTime).format('MMM D, YYYY h:mm A'), + 'End Time': dayjs(fields.endTime).format('MMM D, YYYY h:mm A'), + 'Initial Status': fields.status, }; } } -export type CreatedAuditData = z.infer; - +export type CreatedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts index ca777ea526685a..2e675cf4afb9be 100644 --- a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts @@ -1,29 +1,58 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; -import { ChangeSchema } from "../common/schemas"; +import { BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; /** * Host No-Show Updated Audit Action Service - * Handles HOST_NO_SHOW_UPDATED action + * Handles HOST_NO_SHOW_UPDATED action with per-action versioning + * + * Version History: + * - v1: Initial schema with noShowHost */ -export class HostNoShowUpdatedAuditActionService { - static readonly schema = z.object({ - changes: z.array(ChangeSchema), + +const hostNoShowUpdatedFieldsSchemaV1 = z.object({ + noShowHost: BooleanChangeSchema, +}); + +export class HostNoShowUpdatedAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = hostNoShowUpdatedFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return HostNoShowUpdatedAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: hostNoShowUpdatedFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); } - getDisplaySummary(data: z.infer, t: TFunction): string { + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { return t('audit.host_no_show_updated'); } - getDisplayDetails(data: z.infer, t: TFunction): Record { - return {}; + getDisplayDetails(storedData: { version: number; fields: z.infer }, t: TFunction): Record { + const { fields } = storedData; + return { + 'Host No-Show': `${fields.noShowHost.old ?? false} → ${fields.noShowHost.new}`, + }; } } -export type HostNoShowUpdatedAuditData = z.infer; - +export type HostNoShowUpdatedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/IAuditActionService.ts b/packages/features/booking-audit/lib/actions/IAuditActionService.ts new file mode 100644 index 00000000000000..3222d850f41b02 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/IAuditActionService.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import type { TFunction } from "next-i18next"; + +/** + * Interface for Audit Action Services + * + * Defines the contract that all audit action services must implement. + * Uses composition with AuditActionServiceHelper to provide common functionality + * while maintaining type safety and flexibility for versioned schemas. + * + * @template TFieldsSchema - The Zod schema type for the service's audit fields + */ +export interface IAuditActionService { + /** + * Current version number for this action type + */ + readonly VERSION: number; + + /** + * Fields schema (without version wrapper) for v1 + */ + readonly fieldsSchemaV1: TFieldsSchema; + + /** + * Data schema including version wrapper + * Validates the structure stored in BookingAudit.data column: { version: number, fields: TFieldsSchema } + */ + readonly dataSchema: z.ZodObject<{ + version: z.ZodLiteral; + fields: TFieldsSchema; + }>; + + /** + * Parse input fields and wrap with version for writing to database + * @param input - Raw input fields (just the audit fields) + * @returns Parsed data with version wrapper { version, fields } + */ + parseFields(input: unknown): { version: number; fields: z.infer }; + + /** + * Parse stored audit record (includes version wrapper) + * @param data - Stored data from database + * @returns Parsed stored data { version, fields } + */ + parseStored(data: unknown): { version: number; fields: z.infer }; + + /** + * Extract version number from stored data + * @param data - Stored data from database + * @returns Version number + */ + getVersion(data: unknown): number; + + /** + * Get human-readable summary for display + * @param storedData - Parsed stored data { version, fields } + * @param t - Translation function from next-i18next + * @returns Translated summary string + */ + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string; + + /** + * Get detailed key-value pairs for display + * @param storedData - Parsed stored data { version, fields } + * @param t - Translation function from next-i18next + * @returns Object with display labels as keys and formatted values + */ + getDisplayDetails( + storedData: { version: number; fields: z.infer }, + t: TFunction + ): Record; +} + diff --git a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts index 44adcfd94387d9..71c53c9527bd39 100644 --- a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts @@ -1,36 +1,59 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; -import { ChangeSchema } from "../common/schemas"; +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; /** * Location Changed Audit Action Service - * Handles LOCATION_CHANGED action + * Handles LOCATION_CHANGED action with per-action versioning + * + * Version History: + * - v1: Initial schema with location */ -export class LocationChangedAuditActionService { - static readonly schema = z.object({ - changes: z.array(ChangeSchema), - }); - - parse(data: unknown): z.infer { - return LocationChangedAuditActionService.schema.parse(data); - } - - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.location_changed'); - } - - getDisplayDetails(data: z.infer, t: TFunction): Record { - const locationChange = data.changes.find(c => c.field === 'location'); - if (locationChange) { - return { - 'Old Location': String(locationChange.oldValue || '-'), - 'New Location': String(locationChange.newValue || '-'), - }; - } - return {}; - } -} -export type LocationChangedAuditData = z.infer; +const locationChangedFieldsSchemaV1 = z.object({ + location: StringChangeSchema, +}); + +export class LocationChangedAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = locationChangedFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, + }); + + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: locationChangedFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { + return t('audit.location_changed'); + } + + getDisplayDetails(storedData: { version: number; fields: z.infer }, t: TFunction): Record { + const { fields } = storedData; + return { + 'Previous Location': fields.location.old ?? '-', + 'New Location': fields.location.new ?? '-', + }; + } +} +export type LocationChangedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/MeetingUrlUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/MeetingUrlUpdatedAuditActionService.ts deleted file mode 100644 index 406bca8865ea8d..00000000000000 --- a/packages/features/booking-audit/lib/actions/MeetingUrlUpdatedAuditActionService.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from "zod"; -import type { TFunction } from "next-i18next"; - -import { ChangeSchema } from "../common/schemas"; - -/** - * Meeting URL Updated Audit Action Service - * Handles MEETING_URL_UPDATED action - */ -export class MeetingUrlUpdatedAuditActionService { - static readonly schema = z.object({ - changes: z.array(ChangeSchema), - }); - - parse(data: unknown): z.infer { - return MeetingUrlUpdatedAuditActionService.schema.parse(data); - } - - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.meeting_url_updated'); - } - - getDisplayDetails(data: z.infer, t: TFunction): Record { - const urlChange = data.changes.find(c => c.field === 'meetingUrl'); - if (urlChange) { - return { - 'New Meeting URL': String(urlChange.newValue || '-'), - }; - } - return {}; - } -} - -export type MeetingUrlUpdatedAuditData = z.infer; - diff --git a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts index 38c5f9e78fc226..9ff6070f97f301 100644 --- a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts @@ -1,38 +1,73 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; -import { ChangeSchema, AssignmentDetailsSchema } from "../common/schemas"; +import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; /** * Reassignment Audit Action Service - * Handles REASSIGNMENT_REASON_UPDATED action + * Handles REASSIGNMENT action with per-action versioning + * + * Version History: + * - v1: Initial schema with assignedToId, assignedById, reassignmentReason, userPrimaryEmail, title */ -export class ReassignmentAuditActionService { - static readonly schema = z.object({ - reassignmentReason: z.string(), - assignmentMethod: z.enum(['manual', 'round_robin', 'salesforce', 'routing_form', 'crm_ownership']), - assignmentDetails: AssignmentDetailsSchema, - changes: z.array(ChangeSchema), + +const reassignmentFieldsSchemaV1 = z.object({ + assignedToId: NumberChangeSchema, + assignedById: NumberChangeSchema, + reassignmentReason: StringChangeSchema, + userPrimaryEmail: StringChangeSchema.optional(), + title: StringChangeSchema.optional(), +}); + +export class ReassignmentAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = reassignmentFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return ReassignmentAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: reassignmentFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); } - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.assigned_user', { userName: data.assignmentDetails.assignedUser.name }); + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { + return t('audit.booking_reassigned'); } - getDisplayDetails(data: z.infer, t: TFunction): Record { - return { - 'Type': data.assignmentMethod, - 'From': data.assignmentDetails.previousUser?.name || '-', - 'To': data.assignmentDetails.assignedUser.name, - 'Team': data.assignmentDetails.teamName || '-', - 'Reason': data.reassignmentReason, + getDisplayDetails(storedData: { version: number; fields: z.infer }, _t: TFunction): Record { + const { fields } = storedData; + const details: Record = { + 'Assigned To ID': `${fields.assignedToId.old ?? '-'} → ${fields.assignedToId.new}`, + 'Assigned By ID': `${fields.assignedById.old ?? '-'} → ${fields.assignedById.new}`, + 'Reason': fields.reassignmentReason.new ?? '-', }; + + if (fields.userPrimaryEmail) { + details['Email'] = `${fields.userPrimaryEmail.old ?? '-'} → ${fields.userPrimaryEmail.new ?? '-'}`; + } + if (fields.title) { + details['Title'] = `${fields.title.old ?? '-'} → ${fields.title.new ?? '-'}`; + } + + return details; } } -export type ReassignmentAuditData = z.infer; - +export type ReassignmentAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts index 2695150f34072e..3458e48c03e6a1 100644 --- a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts @@ -1,29 +1,60 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; + /** * Rejected Audit Action Service - * Handles REJECTED action + * Handles REJECTED action with per-action versioning + * + * Version History: + * - v1: Initial schema with rejectionReason, status */ -export class RejectedAuditActionService { - static readonly schema = z.object({ - rejectionReason: z.string(), + +const rejectedFieldsSchemaV1 = z.object({ + rejectionReason: StringChangeSchema, + status: StringChangeSchema, +}); + +export class RejectedAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = rejectedFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return RejectedAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: rejectedFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); } - getDisplaySummary(data: z.infer, t: TFunction): string { + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { return t('audit.rejected_booking'); } - getDisplayDetails(data: z.infer, t: TFunction): Record { + getDisplayDetails(storedData: { version: number; fields: z.infer }, _t: TFunction): Record { + const { fields } = storedData; return { - 'Reason': data.rejectionReason, + 'Rejection Reason': fields.rejectionReason.new ?? '-', + 'Previous Reason': fields.rejectionReason.old ?? '-', }; } } -export type RejectedAuditData = z.infer; - +export type RejectedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/RejectionReasonUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RejectionReasonUpdatedAuditActionService.ts deleted file mode 100644 index cf3ea3db959429..00000000000000 --- a/packages/features/booking-audit/lib/actions/RejectionReasonUpdatedAuditActionService.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from "zod"; -import type { TFunction } from "next-i18next"; - -/** - * Rejection Reason Updated Audit Action Service - * Handles REJECTION_REASON_UPDATED action - */ -export class RejectionReasonUpdatedAuditActionService { - static readonly schema = z.object({ - rejectionReason: z.string(), - }); - - parse(data: unknown): z.infer { - return RejectionReasonUpdatedAuditActionService.schema.parse(data); - } - - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.rejection_reason_updated'); - } - - getDisplayDetails(data: z.infer, t: TFunction): Record { - return { - 'Reason': data.rejectionReason, - }; - } -} - -export type RejectionReasonUpdatedAuditData = z.infer; - diff --git a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts index f8023229ca8934..cc827b6bf6a4bc 100644 --- a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts @@ -1,34 +1,68 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; -import { ChangeSchema } from "../common/schemas"; +import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; /** * Reschedule Requested Audit Action Service - * Handles RESCHEDULE_REQUESTED action + * Handles RESCHEDULE_REQUESTED action with per-action versioning + * + * Version History: + * - v1: Initial schema with cancellationReason, cancelledBy, rescheduled */ -export class RescheduleRequestedAuditActionService { - static readonly schema = z.object({ - cancellationReason: z.string().optional(), - changes: z.array(ChangeSchema), + +const rescheduleRequestedFieldsSchemaV1 = z.object({ + cancellationReason: StringChangeSchema, + cancelledBy: StringChangeSchema, + rescheduled: BooleanChangeSchema.optional(), +}); + +export class RescheduleRequestedAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = rescheduleRequestedFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return RescheduleRequestedAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: rescheduleRequestedFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); } - getDisplaySummary(data: z.infer, t: TFunction): string { + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { return t('audit.reschedule_requested'); } - getDisplayDetails(data: z.infer, t: TFunction): Record { + getDisplayDetails(storedData: { version: number; fields: z.infer }, t: TFunction): Record { + const { fields } = storedData; const details: Record = {}; - if (data.cancellationReason) { - details['Reason'] = data.cancellationReason; + if (fields.cancellationReason) { + details['Reason'] = fields.cancellationReason.new ?? '-'; + } + if (fields.cancelledBy) { + details['Cancelled By'] = `${fields.cancelledBy.old ?? '-'} → ${fields.cancelledBy.new ?? '-'}`; + } + if (fields.rescheduled) { + details['Rescheduled'] = `${fields.rescheduled.old ?? false} → ${fields.rescheduled.new}`; } return details; } } -export type RescheduleRequestedAuditData = z.infer; - +export type RescheduleRequestedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts index a45affe1013510..e7a8c86cc0e82e 100644 --- a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts @@ -2,32 +2,64 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; import dayjs from "@calcom/dayjs"; +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; + /** * Rescheduled Audit Action Service - * Handles RESCHEDULED action + * Handles RESCHEDULED action with per-action versioning + * + * Version History: + * - v1: Initial schema with startTime, endTime */ -export class RescheduledAuditActionService { - static readonly schema = z.object({ - startTime: z.string(), - endTime: z.string(), + +const rescheduledFieldsSchemaV1 = z.object({ + startTime: StringChangeSchema, + endTime: StringChangeSchema, +}); + +export class RescheduledAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = rescheduledFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, }); - parse(data: unknown): z.infer { - return RescheduledAuditActionService.schema.parse(data); + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: rescheduledFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); } - getDisplaySummary(data: z.infer, t: TFunction): string { - const formattedDate = dayjs(data.startTime).format('MMM D, YYYY'); + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { + const { fields } = storedData; + const formattedDate = dayjs(fields.startTime.new).format('MMM D, YYYY'); return t('audit.rescheduled_to', { date: formattedDate }); } - getDisplayDetails(data: z.infer, t: TFunction): Record { + getDisplayDetails(storedData: { version: number; fields: z.infer }, t: TFunction): Record { + const { fields } = storedData; return { - 'New Start Time': dayjs(data.startTime).format('MMM D, YYYY h:mm A'), - 'New End Time': dayjs(data.endTime).format('MMM D, YYYY h:mm A'), + 'Previous Start': fields.startTime.old ? dayjs(fields.startTime.old).format('MMM D, YYYY h:mm A') : '-', + 'New Start': dayjs(fields.startTime.new).format('MMM D, YYYY h:mm A'), + 'Previous End': fields.endTime.old ? dayjs(fields.endTime.old).format('MMM D, YYYY h:mm A') : '-', + 'New End': dayjs(fields.endTime.new).format('MMM D, YYYY h:mm A'), }; } } -export type RescheduledAuditData = z.infer; - +export type RescheduledAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/StatusChangeAuditActionService.ts b/packages/features/booking-audit/lib/actions/StatusChangeAuditActionService.ts index 7fcb5a26fe9733..68d88d58ac53c3 100644 --- a/packages/features/booking-audit/lib/actions/StatusChangeAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/StatusChangeAuditActionService.ts @@ -1,29 +1,58 @@ import { z } from "zod"; import type { TFunction } from "next-i18next"; -import { ChangeSchema } from "../common/schemas"; +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; /** * Status Change Audit Action Service - * Handles ACCEPTED, PENDING, AWAITING_HOST actions + * Handles ACCEPTED, PENDING, AWAITING_HOST actions with per-action versioning + * + * Version History: + * - v1: Initial schema with status */ -export class StatusChangeAuditActionService { - static readonly schema = z.object({ - changes: z.array(ChangeSchema).optional(), - }); - - parse(data: unknown): z.infer { - return StatusChangeAuditActionService.schema.parse(data); - } - - getDisplaySummary(data: z.infer, t: TFunction): string { - return t('audit.status_changed'); - } - - getDisplayDetails(data: z.infer, t: TFunction): Record { - return {}; - } -} -export type StatusChangeAuditData = z.infer; +const statusChangeFieldsSchemaV1 = z.object({ + status: StringChangeSchema, +}); + +export class StatusChangeAuditActionService implements IAuditActionService { + private helper: AuditActionServiceHelper; + + readonly VERSION = 1; + readonly fieldsSchemaV1 = statusChangeFieldsSchemaV1; + readonly dataSchema = z.object({ + version: z.literal(this.VERSION), + fields: this.fieldsSchemaV1, + }); + + constructor() { + this.helper = new AuditActionServiceHelper({ fieldsSchema: statusChangeFieldsSchemaV1, version: this.VERSION }); + } + + parseFields(input: unknown) { + return this.helper.parseFields(input); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + getDisplaySummary(storedData: { version: number; fields: z.infer }, t: TFunction): string { + return t('audit.status_changed'); + } + + getDisplayDetails(storedData: { version: number; fields: z.infer }, t: TFunction): Record { + const { fields } = storedData; + return { + 'Status': `${fields.status.old ?? '-'} → ${fields.status.new ?? '-'}`, + }; + } +} +export type StatusChangeAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/common/changeSchemas.ts b/packages/features/booking-audit/lib/common/changeSchemas.ts new file mode 100644 index 00000000000000..50025e8ec2a750 --- /dev/null +++ b/packages/features/booking-audit/lib/common/changeSchemas.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +/** + * Common change schemas for audit data + * These represent old -> new value transitions + */ + +export const StringChangeSchema = z.object({ + old: z.string().nullable(), + new: z.string().nullable(), +}); + +export const BooleanChangeSchema = z.object({ + old: z.boolean().nullable(), + new: z.boolean(), +}); + +export const StringArrayChangeSchema = z.object({ + old: z.array(z.string()).nullable(), + new: z.array(z.string()), +}); + +export const NumberChangeSchema = z.object({ + old: z.number().nullable(), + new: z.number(), +}); + diff --git a/packages/features/booking-audit/lib/common/schemas.ts b/packages/features/booking-audit/lib/common/schemas.ts deleted file mode 100644 index a78c583b51b5dd..00000000000000 --- a/packages/features/booking-audit/lib/common/schemas.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from "zod"; - -/** - * Schema for tracking field-level changes in audit records - * oldValue and newValue are optional to support creation (no oldValue) and deletion (no newValue) - */ -export const ChangeSchema = z.object({ - field: z.string(), - oldValue: z.unknown().optional(), - newValue: z.unknown().optional(), -}); - -/** - * Schema for assignment/reassignment context - * Stores both IDs (for querying) and full context (for display) - */ -export const AssignmentDetailsSchema = z.object({ - // IDs for querying - teamId: z.number().optional(), - teamName: z.string().optional(), - - // User details (historical snapshot for display) - assignedUser: z.object({ - id: z.number(), - name: z.string(), - email: z.string(), - }), - previousUser: z.object({ - id: z.number(), - name: z.string(), - email: z.string(), - }).optional(), // Optional: first assignment has no previous user -}); - diff --git a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts index 6f506fb64702ae..aa5c37b8adc12a 100644 --- a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts @@ -2,36 +2,14 @@ import type { BookingAudit, Prisma } from "@calcom/prisma/client"; import type { PrismaClient } from "@calcom/prisma"; import prisma from "@calcom/prisma"; -import { BookingAuditDataSchema } from "../types"; import type { IBookingAuditRepository } from "./IBookingAuditRepository"; export class PrismaBookingAuditRepository implements IBookingAuditRepository { constructor(private readonly prismaClient: PrismaClient = prisma) { } async create(bookingAudit: Prisma.BookingAuditCreateInput): Promise { - // Validate data against union schema at write time - if (bookingAudit.data !== null && bookingAudit.data !== undefined) { - // Type guard: check if it's an object with string keys - if (typeof bookingAudit.data === 'object' && !Array.isArray(bookingAudit.data)) { - // Extract version if it exists, keeping the rest for validation - const entries = Object.entries(bookingAudit.data); - const dataWithoutVersion: Record = {}; - - for (const [key, value] of entries) { - if (key !== 'version') { - dataWithoutVersion[key] = value; - } - } - - const validationResult = BookingAuditDataSchema.safeParse(dataWithoutVersion); - if (!validationResult.success) { - throw new Error( - `Invalid audit data for action ${bookingAudit.action}: ${validationResult.error.message}` - ); - } - } - } - + // Validation is now handled by individual action services via their parse() methods + // before data reaches the repository return this.prismaClient.bookingAudit.create({ data: bookingAudit, }); diff --git a/packages/features/booking-audit/lib/service/BookingAuditService.ts b/packages/features/booking-audit/lib/service/BookingAuditService.ts index 74a9e05b4249bb..647811dfb2cbd4 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditService.ts @@ -1,607 +1,437 @@ -import type { BookingAudit, BookingAuditType, BookingAuditAction, Prisma } from "@calcom/prisma/client"; import type { TFunction } from "next-i18next"; +import type { BookingAudit, BookingAuditType, BookingAuditAction, Prisma } from "@calcom/prisma/client"; +import logger from "@calcom/lib/logger"; +interface BookingAuditServiceDeps { + bookingAuditRepository: IBookingAuditRepository; + actorRepository: IActorRepository; +} + +import { AuditActionServiceHelper } from "../actions/AuditActionServiceHelper"; +import { AttendeeAddedAuditActionService, type AttendeeAddedAuditData } from "../actions/AttendeeAddedAuditActionService"; +import { AttendeeNoShowUpdatedAuditActionService, type AttendeeNoShowUpdatedAuditData } from "../actions/AttendeeNoShowUpdatedAuditActionService"; +import { AttendeeRemovedAuditActionService, type AttendeeRemovedAuditData } from "../actions/AttendeeRemovedAuditActionService"; +import { CancelledAuditActionService, type CancelledAuditData } from "../actions/CancelledAuditActionService"; +import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService"; +import { HostNoShowUpdatedAuditActionService, type HostNoShowUpdatedAuditData } from "../actions/HostNoShowUpdatedAuditActionService"; +import { LocationChangedAuditActionService, type LocationChangedAuditData } from "../actions/LocationChangedAuditActionService"; +import { ReassignmentAuditActionService, type ReassignmentAuditData } from "../actions/ReassignmentAuditActionService"; +import { RejectedAuditActionService, type RejectedAuditData } from "../actions/RejectedAuditActionService"; +import { RescheduleRequestedAuditActionService, type RescheduleRequestedAuditData } from "../actions/RescheduleRequestedAuditActionService"; +import { RescheduledAuditActionService, type RescheduledAuditData } from "../actions/RescheduledAuditActionService"; +import { StatusChangeAuditActionService, type StatusChangeAuditData } from "../actions/StatusChangeAuditActionService"; import type { IActorRepository } from "../repository/IActorRepository"; import type { IBookingAuditRepository } from "../repository/IBookingAuditRepository"; -import { PrismaActorRepository } from "../repository/PrismaActorRepository"; -import { PrismaBookingAuditRepository } from "../repository/PrismaBookingAuditRepository"; -import type { - CreatedAuditData, - CancelledAuditData, - RejectedAuditData, - RescheduledAuditData, - RescheduleRequestedAuditData, - AttendeeAddedAuditData, - AttendeeRemovedAuditData, - ReassignmentAuditData, - AssignmentAuditData, - CancellationReasonUpdatedAuditData, - RejectionReasonUpdatedAuditData, - LocationChangedAuditData, - MeetingUrlUpdatedAuditData, - HostNoShowUpdatedAuditData, - AttendeeNoShowUpdatedAuditData, - StatusChangeAuditData, -} from "../types"; -import { CreatedAuditActionService } from "../actions/CreatedAuditActionService"; -import { CancelledAuditActionService } from "../actions/CancelledAuditActionService"; -import { RejectedAuditActionService } from "../actions/RejectedAuditActionService"; -import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; -import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService"; -import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; -import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService"; -import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService"; -import { AssignmentAuditActionService } from "../actions/AssignmentAuditActionService"; -import { CancellationReasonUpdatedAuditActionService } from "../actions/CancellationReasonUpdatedAuditActionService"; -import { RejectionReasonUpdatedAuditActionService } from "../actions/RejectionReasonUpdatedAuditActionService"; -import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService"; -import { MeetingUrlUpdatedAuditActionService } from "../actions/MeetingUrlUpdatedAuditActionService"; -import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService"; -import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService"; -import { StatusChangeAuditActionService } from "../actions/StatusChangeAuditActionService"; type CreateBookingAuditInput = { - bookingId: string; - actorId: string; - type: BookingAuditType; - action: BookingAuditAction; - data?: unknown; - timestamp: Date; // Required: actual time of the booking change (business event) + bookingId: string; + actorId: string; + type: BookingAuditType; + action: BookingAuditAction; + data?: unknown; + timestamp: Date; // Required: actual time of the booking change (business event) }; -const CURRENT_AUDIT_DATA_VERSION = 2; const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; /** * BookingAuditService - Central service for all booking audit operations - * * Handles both write (audit creation) and read (display) operations - * Version is automatically injected into all audit data + * Each action service manages its own schema versioning */ export class BookingAuditService { - private readonly createdActionService: CreatedAuditActionService; - private readonly cancelledActionService: CancelledAuditActionService; - private readonly rejectedActionService: RejectedAuditActionService; - private readonly rescheduledActionService: RescheduledAuditActionService; - private readonly rescheduleRequestedActionService: RescheduleRequestedAuditActionService; - private readonly attendeeAddedActionService: AttendeeAddedAuditActionService; - private readonly attendeeRemovedActionService: AttendeeRemovedAuditActionService; - private readonly assignmentActionService: AssignmentAuditActionService; - private readonly reassignmentActionService: ReassignmentAuditActionService; - private readonly cancellationReasonUpdatedActionService: CancellationReasonUpdatedAuditActionService; - private readonly rejectionReasonUpdatedActionService: RejectionReasonUpdatedAuditActionService; - private readonly locationChangedActionService: LocationChangedAuditActionService; - private readonly meetingUrlUpdatedActionService: MeetingUrlUpdatedAuditActionService; - private readonly hostNoShowUpdatedActionService: HostNoShowUpdatedAuditActionService; - private readonly attendeeNoShowUpdatedActionService: AttendeeNoShowUpdatedAuditActionService; - private readonly statusChangeActionService: StatusChangeAuditActionService; - - constructor( - private readonly bookingAuditRepository: IBookingAuditRepository = new PrismaBookingAuditRepository(), - private readonly actorRepository: IActorRepository = new PrismaActorRepository() - ) { - this.createdActionService = new CreatedAuditActionService(); - this.cancelledActionService = new CancelledAuditActionService(); - this.rejectedActionService = new RejectedAuditActionService(); - this.rescheduledActionService = new RescheduledAuditActionService(); - this.rescheduleRequestedActionService = new RescheduleRequestedAuditActionService(); - this.attendeeAddedActionService = new AttendeeAddedAuditActionService(); - this.attendeeRemovedActionService = new AttendeeRemovedAuditActionService(); - this.assignmentActionService = new AssignmentAuditActionService(); - this.reassignmentActionService = new ReassignmentAuditActionService(); - this.cancellationReasonUpdatedActionService = new CancellationReasonUpdatedAuditActionService(); - this.rejectionReasonUpdatedActionService = new RejectionReasonUpdatedAuditActionService(); - this.locationChangedActionService = new LocationChangedAuditActionService(); - this.meetingUrlUpdatedActionService = new MeetingUrlUpdatedAuditActionService(); - this.hostNoShowUpdatedActionService = new HostNoShowUpdatedAuditActionService(); - this.attendeeNoShowUpdatedActionService = new AttendeeNoShowUpdatedAuditActionService(); - this.statusChangeActionService = new StatusChangeAuditActionService(); - } - - static create(): BookingAuditService { - return new BookingAuditService(); - } - - private async getOrCreateUserActor(userId: number): Promise { - const actor = await this.actorRepository.upsertUserActor(userId); - return actor.id; - } - - /** - * Creates a booking audit record with automatic version injection - */ - private async createAuditRecord(input: CreateBookingAuditInput): Promise { - const auditData: Prisma.BookingAuditCreateInput = { - bookingId: input.bookingId, - actor: { - connect: { - id: input.actorId, - }, - }, - type: input.type, - action: input.action, - timestamp: input.timestamp, // Actual time of the booking change - data: input.data - ? ({ - ...input.data, - version: CURRENT_AUDIT_DATA_VERSION, // Auto-inject version - } as Prisma.InputJsonValue) - : undefined, - }; - - return this.bookingAuditRepository.create(auditData); - } - - // ============== WRITE OPERATIONS (Audit Creation) ============== - - async onBookingCreated( - bookingId: string, - userId: number | undefined, - data: CreatedAuditData - ): Promise { - const parsedData = this.createdActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_CREATED", - action: "CREATED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onBookingAccepted( - bookingId: string, - userId: number | undefined, - data?: StatusChangeAuditData - ): Promise { - const parsedData = data ? this.statusChangeActionService.parse(data) : undefined; - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "ACCEPTED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onBookingRejected( - bookingId: string, - userId: number | undefined, - data: RejectedAuditData - ): Promise { - const parsedData = this.rejectedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "REJECTED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onBookingPending( - bookingId: string, - userId: number | undefined, - data?: StatusChangeAuditData - ): Promise { - const parsedData = data ? this.statusChangeActionService.parse(data) : undefined; - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "PENDING", - data: parsedData, - timestamp: new Date(), - }); - } - - async onBookingAwaitingHost( - bookingId: string, - userId: number | undefined, - data?: StatusChangeAuditData - ): Promise { - const parsedData = data ? this.statusChangeActionService.parse(data) : undefined; - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "AWAITING_HOST", - data: parsedData, - timestamp: new Date(), - }); - } - - - async onBookingCancelled( - bookingId: string, - userId: number | undefined, - data: CancelledAuditData - ): Promise { - const parsedData = this.cancelledActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "CANCELLED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onBookingRescheduled( - bookingId: string, - userId: number | undefined, - data: RescheduledAuditData - ): Promise { - const parsedData = this.rescheduledActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "RESCHEDULED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onRescheduleRequested( - bookingId: string, - userId: number | undefined, - data: RescheduleRequestedAuditData - ): Promise { - const parsedData = this.rescheduleRequestedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "RESCHEDULE_REQUESTED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onAttendeeAdded( - bookingId: string, - userId: number | undefined, - data: AttendeeAddedAuditData - ): Promise { - const parsedData = this.attendeeAddedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "ATTENDEE_ADDED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onAttendeeRemoved( - bookingId: string, - userId: number | undefined, - data: AttendeeRemovedAuditData - ): Promise { - const parsedData = this.attendeeRemovedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "ATTENDEE_REMOVED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onCancellationReasonUpdated( - bookingId: string, - userId: number | undefined, - data: CancellationReasonUpdatedAuditData - ): Promise { - const parsedData = this.cancellationReasonUpdatedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "CANCELLATION_REASON_UPDATED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onRejectionReasonUpdated( - bookingId: string, - userId: number | undefined, - data: RejectionReasonUpdatedAuditData - ): Promise { - const parsedData = this.rejectionReasonUpdatedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "REJECTION_REASON_UPDATED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onAssignmentReasonUpdated( - bookingId: string, - userId: number | undefined, - data: AssignmentAuditData - ): Promise { - const parsedData = this.assignmentActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "ASSIGNMENT_REASON_UPDATED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onReassignmentReasonUpdated( - bookingId: string, - userId: number | undefined, - data: ReassignmentAuditData - ): Promise { - const parsedData = this.reassignmentActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "REASSIGNMENT_REASON_UPDATED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onLocationChanged( - bookingId: string, - userId: number | undefined, - data: LocationChangedAuditData - ): Promise { - const parsedData = this.locationChangedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "LOCATION_CHANGED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onMeetingUrlUpdated( - bookingId: string, - userId: number | undefined, - data: MeetingUrlUpdatedAuditData - ): Promise { - const parsedData = this.meetingUrlUpdatedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "MEETING_URL_UPDATED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onHostNoShowUpdated( - bookingId: string, - userId: number | undefined, - data: HostNoShowUpdatedAuditData - ): Promise { - const parsedData = this.hostNoShowUpdatedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "HOST_NO_SHOW_UPDATED", - data: parsedData, - timestamp: new Date(), - }); - } - - async onAttendeeNoShowUpdated( - bookingId: string, - userId: number | undefined, - data: AttendeeNoShowUpdatedAuditData - ): Promise { - const parsedData = this.attendeeNoShowUpdatedActionService.parse(data); - const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; - return this.createAuditRecord({ - bookingId, - actorId, - type: "RECORD_UPDATED", - action: "ATTENDEE_NO_SHOW_UPDATED", - data: parsedData, - timestamp: new Date(), - }); - } - - // ============== READ OPERATIONS (Display) ============== - - /** - * Get human-readable display summary for audit entry (i18n-aware) - */ - getDisplaySummary(audit: BookingAudit, t: TFunction): string { - switch (audit.action) { - case "CREATED": { - const data = this.createdActionService.parse(audit.data); - return this.createdActionService.getDisplaySummary(data, t); - } - case "ACCEPTED": - case "PENDING": - case "AWAITING_HOST": { - const data = this.statusChangeActionService.parse(audit.data); - return this.statusChangeActionService.getDisplaySummary(data, t); - } - case "CANCELLED": { - const data = this.cancelledActionService.parse(audit.data); - return this.cancelledActionService.getDisplaySummary(data, t); - } - case "REJECTED": { - const data = this.rejectedActionService.parse(audit.data); - return this.rejectedActionService.getDisplaySummary(data, t); - } - case "RESCHEDULED": { - const data = this.rescheduledActionService.parse(audit.data); - return this.rescheduledActionService.getDisplaySummary(data, t); - } - case "RESCHEDULE_REQUESTED": { - const data = this.rescheduleRequestedActionService.parse(audit.data); - return this.rescheduleRequestedActionService.getDisplaySummary(data, t); - } - case "ATTENDEE_ADDED": { - const data = this.attendeeAddedActionService.parse(audit.data); - return this.attendeeAddedActionService.getDisplaySummary(data, t); - } - case "ATTENDEE_REMOVED": { - const data = this.attendeeRemovedActionService.parse(audit.data); - return this.attendeeRemovedActionService.getDisplaySummary(data, t); - } - case "REASSIGNMENT_REASON_UPDATED": { - const data = this.reassignmentActionService.parse(audit.data); - return this.reassignmentActionService.getDisplaySummary(data, t); - } - case "ASSIGNMENT_REASON_UPDATED": { - const data = this.assignmentActionService.parse(audit.data); - return this.assignmentActionService.getDisplaySummary(data, t); - } - case "CANCELLATION_REASON_UPDATED": { - const data = this.cancellationReasonUpdatedActionService.parse(audit.data); - return this.cancellationReasonUpdatedActionService.getDisplaySummary(data, t); - } - case "REJECTION_REASON_UPDATED": { - const data = this.rejectionReasonUpdatedActionService.parse(audit.data); - return this.rejectionReasonUpdatedActionService.getDisplaySummary(data, t); - } - case "LOCATION_CHANGED": { - const data = this.locationChangedActionService.parse(audit.data); - return this.locationChangedActionService.getDisplaySummary(data, t); - } - case "MEETING_URL_UPDATED": { - const data = this.meetingUrlUpdatedActionService.parse(audit.data); - return this.meetingUrlUpdatedActionService.getDisplaySummary(data, t); - } - case "HOST_NO_SHOW_UPDATED": { - const data = this.hostNoShowUpdatedActionService.parse(audit.data); - return this.hostNoShowUpdatedActionService.getDisplaySummary(data, t); - } - case "ATTENDEE_NO_SHOW_UPDATED": { - const data = this.attendeeNoShowUpdatedActionService.parse(audit.data); - return this.attendeeNoShowUpdatedActionService.getDisplaySummary(data, t); - } - default: { - if (audit.type === "RECORD_CREATED") { - const data = this.createdActionService.parse(audit.data); - return this.createdActionService.getDisplaySummary(data, t); - } - return t('audit.action_performed'); - } + private readonly createdActionService: CreatedAuditActionService; + private readonly cancelledActionService: CancelledAuditActionService; + private readonly rejectedActionService: RejectedAuditActionService; + private readonly rescheduledActionService: RescheduledAuditActionService; + private readonly rescheduleRequestedActionService: RescheduleRequestedAuditActionService; + private readonly attendeeAddedActionService: AttendeeAddedAuditActionService; + private readonly attendeeRemovedActionService: AttendeeRemovedAuditActionService; + private readonly reassignmentActionService: ReassignmentAuditActionService; + private readonly locationChangedActionService: LocationChangedAuditActionService; + private readonly hostNoShowUpdatedActionService: HostNoShowUpdatedAuditActionService; + private readonly attendeeNoShowUpdatedActionService: AttendeeNoShowUpdatedAuditActionService; + private readonly statusChangeActionService: StatusChangeAuditActionService; + private readonly bookingAuditRepository: IBookingAuditRepository; + private readonly actorRepository: IActorRepository; + + constructor(private readonly deps: BookingAuditServiceDeps) { + this.bookingAuditRepository = deps.bookingAuditRepository; + this.actorRepository = deps.actorRepository; + + // Each service instantiates its own helper with its specific schema + this.createdActionService = new CreatedAuditActionService(); + this.cancelledActionService = new CancelledAuditActionService(); + this.rejectedActionService = new RejectedAuditActionService(); + this.rescheduledActionService = new RescheduledAuditActionService(); + this.rescheduleRequestedActionService = new RescheduleRequestedAuditActionService(); + this.attendeeAddedActionService = new AttendeeAddedAuditActionService(); + this.attendeeRemovedActionService = new AttendeeRemovedAuditActionService(); + this.reassignmentActionService = new ReassignmentAuditActionService(); + this.locationChangedActionService = new LocationChangedAuditActionService(); + this.hostNoShowUpdatedActionService = new HostNoShowUpdatedAuditActionService(); + this.attendeeNoShowUpdatedActionService = new AttendeeNoShowUpdatedAuditActionService(); + this.statusChangeActionService = new StatusChangeAuditActionService(); + } + + private async getOrCreateUserActor(userId: number): Promise { + const actor = await this.actorRepository.upsertUserActor(userId); + return actor.id; + } + + /** + * Creates a booking audit record + * Action services handle their own version wrapping + */ + private async createAuditRecord(input: CreateBookingAuditInput): Promise { + logger.info("Creating audit record", { input }); + const auditData: Prisma.BookingAuditCreateInput = { + bookingId: input.bookingId, + actor: { + connect: { + id: input.actorId, + }, + }, + type: input.type, + action: input.action, + timestamp: input.timestamp, // Actual time of the booking change + data: input.data as Prisma.InputJsonValue, + }; + + return this.bookingAuditRepository.create(auditData); + } + + // ============== WRITE OPERATIONS (Audit Creation) ============== + + async onBookingCreated( + bookingId: string, + userId: number | undefined, + data: CreatedAuditData + ): Promise { + const parsedData = this.createdActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_CREATED", + action: "CREATED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onBookingAccepted( + bookingId: string, + userId: number | undefined, + data?: StatusChangeAuditData + ): Promise { + const parsedData = data ? this.statusChangeActionService.parseFields(data) : undefined; + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "ACCEPTED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onBookingRejected( + bookingId: string, + userId: number | undefined, + data: RejectedAuditData + ): Promise { + const parsedData = this.rejectedActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "REJECTED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onBookingCancelled( + bookingId: string, + userId: number | undefined, + data: CancelledAuditData + ): Promise { + const parsedData = this.cancelledActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "CANCELLED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onBookingRescheduled( + bookingId: string, + userId: number | undefined, + data: RescheduledAuditData + ): Promise { + const parsedData = this.rescheduledActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "RESCHEDULED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onRescheduleRequested( + bookingId: string, + userId: number | undefined, + data: RescheduleRequestedAuditData + ): Promise { + const parsedData = this.rescheduleRequestedActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "RESCHEDULE_REQUESTED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onAttendeeAdded( + bookingId: string, + userId: number | undefined, + data: AttendeeAddedAuditData + ): Promise { + const parsedData = this.attendeeAddedActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "ATTENDEE_ADDED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onAttendeeRemoved( + bookingId: string, + userId: number | undefined, + data: AttendeeRemovedAuditData + ): Promise { + const parsedData = this.attendeeRemovedActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "ATTENDEE_REMOVED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onReassignment( + bookingId: string, + userId: number | undefined, + data: ReassignmentAuditData + ): Promise { + const parsedData = this.reassignmentActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "REASSIGNMENT", + data: parsedData, + timestamp: new Date(), + }); + } + + async onLocationChanged( + bookingId: string, + userId: number | undefined, + data: LocationChangedAuditData + ): Promise { + const parsedData = this.locationChangedActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "LOCATION_CHANGED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onHostNoShowUpdated( + bookingId: string, + userId: number | undefined, + data: HostNoShowUpdatedAuditData + ): Promise { + const parsedData = this.hostNoShowUpdatedActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "HOST_NO_SHOW_UPDATED", + data: parsedData, + timestamp: new Date(), + }); + } + + async onAttendeeNoShowUpdated( + bookingId: string, + userId: number | undefined, + data: AttendeeNoShowUpdatedAuditData + ): Promise { + const parsedData = this.attendeeNoShowUpdatedActionService.parseFields(data); + const actorId = userId ? await this.getOrCreateUserActor(userId) : SYSTEM_ACTOR_ID; + return this.createAuditRecord({ + bookingId, + actorId, + type: "RECORD_UPDATED", + action: "ATTENDEE_NO_SHOW_UPDATED", + data: parsedData, + timestamp: new Date(), + }); + } + + // ============== READ OPERATIONS (Display) ============== + + /** + * Get human-readable display summary for audit entry (i18n-aware) + */ + getDisplaySummary(audit: BookingAudit, t: TFunction): string { + switch (audit.action) { + case "CREATED": { + const data = this.createdActionService.parseStored(audit.data); + return this.createdActionService.getDisplaySummary(data, t); + } + case "ACCEPTED": { + const data = this.statusChangeActionService.parseStored(audit.data); + return this.statusChangeActionService.getDisplaySummary(data, t); + } + case "CANCELLED": { + const data = this.cancelledActionService.parseStored(audit.data); + return this.cancelledActionService.getDisplaySummary(data, t); + } + case "REJECTED": { + const data = this.rejectedActionService.parseStored(audit.data); + return this.rejectedActionService.getDisplaySummary(data, t); + } + case "RESCHEDULED": { + const data = this.rescheduledActionService.parseStored(audit.data); + return this.rescheduledActionService.getDisplaySummary(data, t); + } + case "RESCHEDULE_REQUESTED": { + const data = this.rescheduleRequestedActionService.parseStored(audit.data); + return this.rescheduleRequestedActionService.getDisplaySummary(data, t); + } + case "ATTENDEE_ADDED": { + const data = this.attendeeAddedActionService.parseStored(audit.data); + return this.attendeeAddedActionService.getDisplaySummary(data, t); + } + case "ATTENDEE_REMOVED": { + const data = this.attendeeRemovedActionService.parseStored(audit.data); + return this.attendeeRemovedActionService.getDisplaySummary(data, t); + } + case "REASSIGNMENT": { + const data = this.reassignmentActionService.parseStored(audit.data); + return this.reassignmentActionService.getDisplaySummary(data, t); + } + case "LOCATION_CHANGED": { + const data = this.locationChangedActionService.parseStored(audit.data); + return this.locationChangedActionService.getDisplaySummary(data, t); + } + case "HOST_NO_SHOW_UPDATED": { + const data = this.hostNoShowUpdatedActionService.parseStored(audit.data); + return this.hostNoShowUpdatedActionService.getDisplaySummary(data, t); + } + case "ATTENDEE_NO_SHOW_UPDATED": { + const data = this.attendeeNoShowUpdatedActionService.parseStored(audit.data); + return this.attendeeNoShowUpdatedActionService.getDisplaySummary(data, t); + } + default: { + if (audit.type === "RECORD_CREATED") { + const data = this.createdActionService.parseStored(audit.data); + return this.createdActionService.getDisplaySummary(data, t); } + return t("audit.action_performed"); + } } - - /** - * Get detailed key-value pairs for audit entry display - */ - getDisplayDetails(audit: BookingAudit, t: TFunction): Record { - switch (audit.action) { - case "CREATED": { - const data = this.createdActionService.parse(audit.data); - return this.createdActionService.getDisplayDetails(data, t); - } - case "ACCEPTED": - case "PENDING": - case "AWAITING_HOST": { - const data = this.statusChangeActionService.parse(audit.data); - return this.statusChangeActionService.getDisplayDetails(data, t); - } - case "CANCELLED": { - const data = this.cancelledActionService.parse(audit.data); - return this.cancelledActionService.getDisplayDetails(data, t); - } - case "REJECTED": { - const data = this.rejectedActionService.parse(audit.data); - return this.rejectedActionService.getDisplayDetails(data, t); - } - case "RESCHEDULED": { - const data = this.rescheduledActionService.parse(audit.data); - return this.rescheduledActionService.getDisplayDetails(data, t); - } - case "RESCHEDULE_REQUESTED": { - const data = this.rescheduleRequestedActionService.parse(audit.data); - return this.rescheduleRequestedActionService.getDisplayDetails(data, t); - } - case "ATTENDEE_ADDED": { - const data = this.attendeeAddedActionService.parse(audit.data); - return this.attendeeAddedActionService.getDisplayDetails(data, t); - } - case "ATTENDEE_REMOVED": { - const data = this.attendeeRemovedActionService.parse(audit.data); - return this.attendeeRemovedActionService.getDisplayDetails(data, t); - } - case "REASSIGNMENT_REASON_UPDATED": { - const data = this.reassignmentActionService.parse(audit.data); - return this.reassignmentActionService.getDisplayDetails(data, t); - } - case "ASSIGNMENT_REASON_UPDATED": { - const data = this.assignmentActionService.parse(audit.data); - return this.assignmentActionService.getDisplayDetails(data, t); - } - case "CANCELLATION_REASON_UPDATED": { - const data = this.cancellationReasonUpdatedActionService.parse(audit.data); - return this.cancellationReasonUpdatedActionService.getDisplayDetails(data, t); - } - case "REJECTION_REASON_UPDATED": { - const data = this.rejectionReasonUpdatedActionService.parse(audit.data); - return this.rejectionReasonUpdatedActionService.getDisplayDetails(data, t); - } - case "LOCATION_CHANGED": { - const data = this.locationChangedActionService.parse(audit.data); - return this.locationChangedActionService.getDisplayDetails(data, t); - } - case "MEETING_URL_UPDATED": { - const data = this.meetingUrlUpdatedActionService.parse(audit.data); - return this.meetingUrlUpdatedActionService.getDisplayDetails(data, t); - } - case "HOST_NO_SHOW_UPDATED": { - const data = this.hostNoShowUpdatedActionService.parse(audit.data); - return this.hostNoShowUpdatedActionService.getDisplayDetails(data, t); - } - case "ATTENDEE_NO_SHOW_UPDATED": { - const data = this.attendeeNoShowUpdatedActionService.parse(audit.data); - return this.attendeeNoShowUpdatedActionService.getDisplayDetails(data, t); - } - default: { - if (audit.type === "RECORD_CREATED") { - const data = this.createdActionService.parse(audit.data); - return this.createdActionService.getDisplayDetails(data, t); - } - return {}; - } + } + + /** + * Get detailed key-value pairs for audit entry display + */ + getDisplayDetails(audit: BookingAudit, t: TFunction): Record { + switch (audit.action) { + case "CREATED": { + const data = this.createdActionService.parseStored(audit.data); + return this.createdActionService.getDisplayDetails(data, t); + } + case "ACCEPTED": { + const data = this.statusChangeActionService.parseStored(audit.data); + return this.statusChangeActionService.getDisplayDetails(data, t); + } + case "CANCELLED": { + const data = this.cancelledActionService.parseStored(audit.data); + return this.cancelledActionService.getDisplayDetails(data, t); + } + case "REJECTED": { + const data = this.rejectedActionService.parseStored(audit.data); + return this.rejectedActionService.getDisplayDetails(data, t); + } + case "RESCHEDULED": { + const data = this.rescheduledActionService.parseStored(audit.data); + return this.rescheduledActionService.getDisplayDetails(data, t); + } + case "RESCHEDULE_REQUESTED": { + const data = this.rescheduleRequestedActionService.parseStored(audit.data); + return this.rescheduleRequestedActionService.getDisplayDetails(data, t); + } + case "ATTENDEE_ADDED": { + const data = this.attendeeAddedActionService.parseStored(audit.data); + return this.attendeeAddedActionService.getDisplayDetails(data, t); + } + case "ATTENDEE_REMOVED": { + const data = this.attendeeRemovedActionService.parseStored(audit.data); + return this.attendeeRemovedActionService.getDisplayDetails(data, t); + } + case "REASSIGNMENT": { + const data = this.reassignmentActionService.parseStored(audit.data); + return this.reassignmentActionService.getDisplayDetails(data, t); + } + case "LOCATION_CHANGED": { + const data = this.locationChangedActionService.parseStored(audit.data); + return this.locationChangedActionService.getDisplayDetails(data, t); + } + case "HOST_NO_SHOW_UPDATED": { + const data = this.hostNoShowUpdatedActionService.parseStored(audit.data); + return this.hostNoShowUpdatedActionService.getDisplayDetails(data, t); + } + case "ATTENDEE_NO_SHOW_UPDATED": { + const data = this.attendeeNoShowUpdatedActionService.parseStored(audit.data); + return this.attendeeNoShowUpdatedActionService.getDisplayDetails(data, t); + } + default: { + if (audit.type === "RECORD_CREATED") { + const data = this.createdActionService.parseStored(audit.data); + return this.createdActionService.getDisplayDetails(data, t); } + return {}; + } } + } } diff --git a/packages/features/booking-audit/lib/types/index.ts b/packages/features/booking-audit/lib/types/index.ts deleted file mode 100644 index 4edb702b75eb9a..00000000000000 --- a/packages/features/booking-audit/lib/types/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { z } from "zod"; - -import { CreatedAuditActionService } from "../actions/CreatedAuditActionService"; -import { CancelledAuditActionService } from "../actions/CancelledAuditActionService"; -import { RejectedAuditActionService } from "../actions/RejectedAuditActionService"; -import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; -import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService"; -import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; -import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService"; -import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService"; -import { AssignmentAuditActionService } from "../actions/AssignmentAuditActionService"; -import { CancellationReasonUpdatedAuditActionService } from "../actions/CancellationReasonUpdatedAuditActionService"; -import { RejectionReasonUpdatedAuditActionService } from "../actions/RejectionReasonUpdatedAuditActionService"; -import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService"; -import { MeetingUrlUpdatedAuditActionService } from "../actions/MeetingUrlUpdatedAuditActionService"; -import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService"; -import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService"; -import { StatusChangeAuditActionService } from "../actions/StatusChangeAuditActionService"; - -/** - * Union of all audit data schemas for repository validation - * Used to validate audit data at write time - */ -export const BookingAuditDataSchema = z.union([ - CreatedAuditActionService.schema, - CancelledAuditActionService.schema, - RejectedAuditActionService.schema, - RescheduledAuditActionService.schema, - RescheduleRequestedAuditActionService.schema, - AttendeeAddedAuditActionService.schema, - AttendeeRemovedAuditActionService.schema, - ReassignmentAuditActionService.schema, - AssignmentAuditActionService.schema, - CancellationReasonUpdatedAuditActionService.schema, - RejectionReasonUpdatedAuditActionService.schema, - LocationChangedAuditActionService.schema, - MeetingUrlUpdatedAuditActionService.schema, - HostNoShowUpdatedAuditActionService.schema, - AttendeeNoShowUpdatedAuditActionService.schema, - StatusChangeAuditActionService.schema, -]); - -// Re-export all types -export * from "../actions/CreatedAuditActionService"; -export * from "../actions/CancelledAuditActionService"; -export * from "../actions/RejectedAuditActionService"; -export * from "../actions/RescheduledAuditActionService"; -export * from "../actions/RescheduleRequestedAuditActionService"; -export * from "../actions/AttendeeAddedAuditActionService"; -export * from "../actions/AttendeeRemovedAuditActionService"; -export * from "../actions/ReassignmentAuditActionService"; -export * from "../actions/AssignmentAuditActionService"; -export * from "../actions/CancellationReasonUpdatedAuditActionService"; -export * from "../actions/RejectionReasonUpdatedAuditActionService"; -export * from "../actions/LocationChangedAuditActionService"; -export * from "../actions/MeetingUrlUpdatedAuditActionService"; -export * from "../actions/HostNoShowUpdatedAuditActionService"; -export * from "../actions/AttendeeNoShowUpdatedAuditActionService"; -export * from "../actions/StatusChangeAuditActionService"; diff --git a/packages/features/booking-audit/todo.md b/packages/features/booking-audit/todo.md new file mode 100644 index 00000000000000..e40961a29f1b19 --- /dev/null +++ b/packages/features/booking-audit/todo.md @@ -0,0 +1,53 @@ +# Booking Audit - TODO + +## Pending Tasks + +### 1. Add audit log for spam-blocked bookings (Early Return Path) +**Location**: `packages/features/bookings/lib/service/RegularBookingService.ts` (line ~1486) + +**Issue**: When spam check blocks a booking and returns a decoy response, no audit log is created. + +**Details**: +- Spam-blocked bookings return early with `isShortCircuitedBooking: true` +- These decoy bookings have `id: 0` and are never saved to the database +- No audit trail exists for these blocked attempts + +**Considerations**: +- Should we create audit logs for bookings that never actually exist in the database? +- If yes, we need a special audit entry type (e.g., "BOOKING_BLOCKED_SPAM") +- Actor would be the blocked user (email/phone) +- Need to decide if this should be a security audit log vs booking audit log + +**Recommendation**: Consider creating a separate security/spam audit log system rather than using booking audit for non-existent bookings. + +--- + +### 2. Add audit log for seats booking early return +**Location**: `packages/features/bookings/lib/service/RegularBookingService.ts` (line ~1605) + +**Issue**: When `handleSeats()` returns a booking (adding a new attendee to an existing seated event), the early return at line 1605 bypasses `onBookingCreated()`. + +**Details**: +- Happens when adding attendees to existing seated bookings +- The function returns at line 1605 without calling `deps.bookingEventHandler.onBookingCreated()` +- Result: No audit log is created for the new booking/seat + +**Solution**: +- Add `onBookingCreated()` call before the return statement at line 1605 +- Similar to the fix implemented for PENDING bookings (line 675) +- Should capture the booking creation even when it's a seat addition + +**Priority**: High - This affects actual bookings that exist in the database + +--- + +## Completed Tasks + +### ✅ 1. Add audit log for PENDING bookings early return +**Fixed**: 2024 (PR/Commit TBD) +- Created `buildBookingCreatedPayload()` helper function (line 416) to avoid code duplication +- Added `onBookingCreatedPayload()` call at line 698 before early return using the helper +- Updated main booking creation path (line 2277) to also use the helper function +- Ensures audit logs are created when existing PENDING bookings are returned +- Handles both new and existing bookings in the PENDING state + diff --git a/packages/features/bookings/di/BookingEventHandlerService.container.ts b/packages/features/bookings/di/BookingEventHandlerService.container.ts index f204508c42ca57..31ab743888c0f5 100644 --- a/packages/features/bookings/di/BookingEventHandlerService.container.ts +++ b/packages/features/bookings/di/BookingEventHandlerService.container.ts @@ -1,6 +1,4 @@ import { createContainer } from "@calcom/features/di/di"; -import { loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; -import { SHARED_TOKENS } from "@calcom/features/di/shared/shared.tokens"; import { type BookingEventHandlerService, @@ -9,10 +7,9 @@ import { const container = createContainer(); -export function getBookingEventHandlerService(): BookingEventHandlerService { - // Load logger module - container.load(SHARED_TOKENS.LOGGER, loggerServiceModule); - +export function getBookingEventHandlerService() { + + // Load the booking event handler module bookingEventHandlerServiceModule.loadModule(container); return container.get(bookingEventHandlerServiceModule.token); diff --git a/packages/features/bookings/di/BookingEventHandlerService.module.ts b/packages/features/bookings/di/BookingEventHandlerService.module.ts index c4acfbe6d36258..b7be1b08569593 100644 --- a/packages/features/bookings/di/BookingEventHandlerService.module.ts +++ b/packages/features/bookings/di/BookingEventHandlerService.module.ts @@ -1,35 +1,26 @@ import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; -import { SHARED_TOKENS } from "@calcom/features/di/shared/shared.tokens"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { moduleLoader as hashedLinkServiceModuleLoader } from "@calcom/features/hashedLink/di/HashedLinkService.module"; -import { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService"; +import { moduleLoader as bookingAuditServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditService.module"; +import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service"; const thisModule = createModule(); const token = DI_TOKENS.BOOKING_EVENT_HANDLER_SERVICE; const moduleToken = DI_TOKENS.BOOKING_EVENT_HANDLER_SERVICE_MODULE; -// Custom factory to handle BookingAuditService creation -thisModule.bind(token).toFactory(async (container: any) => { - const log = await container.get(SHARED_TOKENS.LOGGER); - const hashedLinkServiceToken = await hashedLinkServiceModuleLoader.loadModule(container); - const hashedLinkService = await container.get(hashedLinkServiceToken); - const bookingAuditService = BookingAuditService.create(); - - return new BookingEventHandlerService({ - log, - hashedLinkService, - bookingAuditService, - }); +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: BookingEventHandlerService, + depsMap: { + hashedLinkService: hashedLinkServiceModuleLoader, + bookingAuditService: bookingAuditServiceModuleLoader, + log: loggerModuleLoader, + }, }); -const loadModule = async (container: any) => { - if (!container.isBound(moduleToken)) { - container.load(moduleToken, thisModule); - } - return token; -}; - export const moduleLoader = { token, loadModule, diff --git a/packages/features/bookings/di/RegularBookingService.module.ts b/packages/features/bookings/di/RegularBookingService.module.ts index 7b6f02dcace9b2..44cd1e5b4db108 100644 --- a/packages/features/bookings/di/RegularBookingService.module.ts +++ b/packages/features/bookings/di/RegularBookingService.module.ts @@ -1,3 +1,4 @@ +import { moduleLoader as bookingAuditServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditService.module"; import { RegularBookingService } from "@calcom/features/bookings/lib/service/RegularBookingService"; import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; import { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking"; diff --git a/packages/features/bookings/lib/getBookingToDelete.ts b/packages/features/bookings/lib/getBookingToDelete.ts index 7310589c515d04..a971e3f545ca68 100644 --- a/packages/features/bookings/lib/getBookingToDelete.ts +++ b/packages/features/bookings/lib/getBookingToDelete.ts @@ -103,6 +103,8 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u iCalUID: true, iCalSequence: true, status: true, + cancellationReason: true, + cancelledBy: true, }, }); } diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 01b40ff05a2cf1..ab8e1c2cbf45b1 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -5,6 +5,7 @@ import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApi import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmailsAndSMS } from "@calcom/emails"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { processNoShowFeeOnCancellation } from "@calcom/features/bookings/lib/payment/processNoShowFeeOnCancellation"; @@ -50,6 +51,8 @@ import { getBookingToDelete } from "./getBookingToDelete"; import { handleInternalNote } from "./handleInternalNote"; import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat"; import type { IBookingCancelService } from "./interfaces/IBookingCancelService"; +import { createUserActor } from "./types/actor"; +import type { Actor } from "./types/actor"; const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] }); @@ -66,6 +69,12 @@ export type BookingToDelete = Awaited>; export type CancelBookingInput = { userId?: number; bookingData: z.infer; + /** + * The actor performing the cancellation. + * Used for audit logging to track who cancelled the booking. + * Optional for backward compatibility - defaults to System actor if not provided. + */ + actor?: Actor; } & PlatformParams; async function handler(input: CancelBookingInput) { @@ -291,17 +300,17 @@ async function handler(input: CancelBookingInput) { destinationCalendar: bookingToDelete?.destinationCalendar ? [bookingToDelete?.destinationCalendar] : bookingToDelete?.user.destinationCalendar - ? [bookingToDelete?.user.destinationCalendar] - : [], + ? [bookingToDelete?.user.destinationCalendar] + : [], cancellationReason: cancellationReason, ...(teamMembers && teamId && { - team: { - name: bookingToDelete?.eventType?.team?.name || "Nameless", - members: teamMembers, - id: teamId, - }, - }), + team: { + name: bookingToDelete?.eventType?.team?.name || "Nameless", + members: teamMembers, + id: teamId, + }, + }), seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot, seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees, iCalUID: bookingToDelete.iCalUID, @@ -472,6 +481,30 @@ async function handler(input: CancelBookingInput) { }); updatedBookings.push(updatedBooking); + try { + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onBookingCancelled( + String(updatedBooking.id), + createUserActor(userId || 0), + { + cancellationReason: { + old: bookingToDelete.cancellationReason, + new: cancellationReason ?? null, + }, + cancelledBy: { + old: bookingToDelete.cancelledBy, + new: cancelledBy ?? null, + }, + status: { + old: bookingToDelete.status, + new: "CANCELLED", + }, + } + ); + } catch (error) { + log.error("Failed to create booking audit log for cancellation", error); + } + if (bookingToDelete.payment.some((payment) => payment.paymentOption === "ON_BOOKING")) { try { await processPaymentRefund({ @@ -612,7 +645,7 @@ type BookingCancelServiceDependencies = { * Handles both individual booking cancellations and bulk cancellations for recurring events. */ export class BookingCancelService implements IBookingCancelService { - constructor(private readonly deps: BookingCancelServiceDependencies) {} + constructor(private readonly deps: BookingCancelServiceDependencies) { } async cancelBooking(input: { bookingData: CancelRegularBookingData; bookingMeta?: CancelBookingMeta }) { const cancelBookingInput: CancelBookingInput = { diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 839f7735116594..3ae3462f71e7fe 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -1,6 +1,8 @@ import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils"; import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder"; import { sendScheduledEmailsAndSMS } from "@calcom/emails"; +import type { StatusChangeAuditData } from "@calcom/features/booking-audit/lib/actions/StatusChangeAuditActionService"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import type { EventManagerUser } from "@calcom/features/bookings/lib/EventManager"; import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; @@ -30,6 +32,7 @@ import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calenda import { getCalEventResponses } from "./getCalEventResponses"; import { scheduleNoShowTriggers } from "./handleNewBooking/scheduleNoShowTriggers"; +import { createUserActor } from "./types/actor"; const log = logger.getSubLogger({ prefix: ["[handleConfirmation] book:user"] }); @@ -43,6 +46,7 @@ export async function handleConfirmation(args: { startTime: Date; id: number; uid: string; + status: BookingStatus; eventType: { currency: string; description: string | null; @@ -110,7 +114,7 @@ export async function handleConfirmation(args: { metadata.entryPoints = results[0].createdEvent?.entryPoints; } try { - const eventType = booking.eventType; + const _eventType = booking.eventType; let isHostConfirmationEmailsDisabled = false; let isAttendeeConfirmationEmailDisabled = false; @@ -314,6 +318,23 @@ export async function handleConfirmation(args: { }, }); updatedBookings.push(updatedBooking); + + try { + const bookingEventHandlerService = getBookingEventHandlerService(); + const auditData: StatusChangeAuditData = { + status: { + old: booking.status, + new: BookingStatus.ACCEPTED, + }, + }; + await bookingEventHandlerService.onBookingAccepted( + String(updatedBooking.id), + createUserActor(booking.userId || 0), + auditData + ); + } catch (error) { + log.error("Failed to create booking audit log for confirmation", error); + } } const teamId = await getTeamIdFromEventType({ diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts index 0bb8af0c7a02c2..365105b00bbb52 100644 --- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -1,29 +1,23 @@ import type { Logger } from "tslog"; +import type { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService"; +import type { StatusChangeAuditData } from "@calcom/features/booking-audit/lib/actions/StatusChangeAuditActionService"; +import type { CancelledAuditData } from "@calcom/features/booking-audit/lib/actions/CancelledAuditActionService"; +import type { RejectedAuditData } from "@calcom/features/booking-audit/lib/actions/RejectedAuditActionService"; +import type { RescheduleRequestedAuditData } from "@calcom/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService"; +import type { AttendeeAddedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeAddedAuditActionService"; +import type { AttendeeRemovedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService"; +import type { ReassignmentAuditData } from "@calcom/features/booking-audit/lib/actions/ReassignmentAuditActionService"; +import type { LocationChangedAuditData } from "@calcom/features/booking-audit/lib/actions/LocationChangedAuditActionService"; +import type { HostNoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService"; +import type { AttendeeNoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService"; import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; import { safeStringify } from "@calcom/lib/safeStringify"; -import type { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService"; import type { BookingStatus } from "@calcom/prisma/enums"; -import type { - StatusChangeAuditData, - CancelledAuditData, - RejectedAuditData, - RescheduleRequestedAuditData, - AttendeeAddedAuditData, - AttendeeRemovedAuditData, - CancellationReasonUpdatedAuditData, - RejectionReasonUpdatedAuditData, - AssignmentAuditData, - ReassignmentAuditData, - LocationChangedAuditData, - MeetingUrlUpdatedAuditData, - HostNoShowUpdatedAuditData, - AttendeeNoShowUpdatedAuditData, -} from "@calcom/features/booking-audit/lib/types"; import type { Actor } from "../types/actor"; import { getActorUserId } from "../types/actor"; -import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types"; +import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types.d"; interface BookingEventHandlerDeps { log: Logger; @@ -31,6 +25,25 @@ interface BookingEventHandlerDeps { bookingAuditService: BookingAuditService; } +// Type guard functions for discriminating audit data types +function isStatusChangeAuditData( + data: StatusChangeAuditData | CancelledAuditData | RejectedAuditData | undefined +): data is StatusChangeAuditData { + return data !== undefined && "status" in data && !("cancellationReason" in data) && !("rejectionReason" in data); +} + +function isCancelledAuditData( + data: StatusChangeAuditData | CancelledAuditData | RejectedAuditData | undefined +): data is CancelledAuditData { + return data !== undefined && "cancellationReason" in data; +} + +function isRejectedAuditData( + data: StatusChangeAuditData | CancelledAuditData | RejectedAuditData | undefined +): data is RejectedAuditData { + return data !== undefined && "rejectionReason" in data; +} + export class BookingEventHandlerService { private readonly log: BookingEventHandlerDeps["log"]; private readonly hashedLinkService: BookingEventHandlerDeps["hashedLinkService"]; @@ -72,6 +85,27 @@ export class BookingEventHandlerService { return; } await this.onBookingCreatedOrRescheduled(payload); + + try { + const auditData = { + startTime: { + old: payload.oldBooking?.startTime.toISOString() ?? null, + new: payload.booking.startTime.toISOString(), + }, + endTime: { + old: payload.oldBooking?.endTime.toISOString() ?? null, + new: payload.booking.endTime.toISOString(), + }, + }; + const userId = payload.booking.userId ?? payload.booking.user?.id ?? undefined; + await this.bookingAuditService.onBookingRescheduled( + String(payload.booking.id), + userId, + auditData + ); + } catch (error) { + this.log.error("Error while creating booking rescheduled audit", safeStringify(error)); + } } /** @@ -151,23 +185,7 @@ export class BookingEventHandlerService { } } - async onBookingPending(bookingId: string, actor: Actor, data?: StatusChangeAuditData) { - try { - await this.bookingAuditService.onBookingPending(bookingId, getActorUserId(actor), data); - } catch (error) { - this.log.error("Error while creating booking pending audit", safeStringify(error)); - } - } - - async onBookingAwaitingHost(bookingId: string, actor: Actor, data?: StatusChangeAuditData) { - try { - await this.bookingAuditService.onBookingAwaitingHost(bookingId, getActorUserId(actor), data); - } catch (error) { - this.log.error("Error while creating booking awaiting host audit", safeStringify(error)); - } - } - - async onBookingStatusChange( + private async onBookingStatusChange( bookingId: string, actor: Actor, status: BookingStatus, @@ -178,39 +196,24 @@ export class BookingEventHandlerService { switch (status) { case "ACCEPTED": { // Type guard: ensure data is StatusChangeAuditData or undefined - const statusData: StatusChangeAuditData | undefined = - data && !('rejectionReason' in data) && !('cancellationReason' in data) ? data : undefined; + const statusData = isStatusChangeAuditData(data) ? data : undefined; await this.bookingAuditService.onBookingAccepted(bookingId, getActorUserId(actor), statusData); break; } case "REJECTED": { // Caller must provide RejectedAuditData for REJECTED status - if (data && 'rejectionReason' in data) { + if (isRejectedAuditData(data)) { await this.bookingAuditService.onBookingRejected(bookingId, getActorUserId(actor), data); } break; } case "CANCELLED": { // Caller must provide CancelledAuditData for CANCELLED status - if (data && 'cancellationReason' in data) { + if (isCancelledAuditData(data)) { await this.bookingAuditService.onBookingCancelled(bookingId, getActorUserId(actor), data); } break; } - case "PENDING": { - // Type guard: ensure data is StatusChangeAuditData or undefined - const statusData: StatusChangeAuditData | undefined = - data && !('rejectionReason' in data) && !('cancellationReason' in data) ? data : undefined; - await this.bookingAuditService.onBookingPending(bookingId, getActorUserId(actor), statusData); - break; - } - case "AWAITING_HOST": { - // Type guard: ensure data is StatusChangeAuditData or undefined - const statusData: StatusChangeAuditData | undefined = - data && !('rejectionReason' in data) && !('cancellationReason' in data) ? data : undefined; - await this.bookingAuditService.onBookingAwaitingHost(bookingId, getActorUserId(actor), statusData); - break; - } } } catch (error) { this.log.error( @@ -228,35 +231,11 @@ export class BookingEventHandlerService { } } - async onCancellationReasonUpdated(bookingId: string, actor: Actor, data: CancellationReasonUpdatedAuditData) { + async onReassignment(bookingId: string, actor: Actor, data: ReassignmentAuditData) { try { - await this.bookingAuditService.onCancellationReasonUpdated(bookingId, getActorUserId(actor), data); + await this.bookingAuditService.onReassignment(bookingId, getActorUserId(actor), data); } catch (error) { - this.log.error("Error while creating cancellation reason updated audit", safeStringify(error)); - } - } - - async onRejectionReasonUpdated(bookingId: string, actor: Actor, data: RejectionReasonUpdatedAuditData) { - try { - await this.bookingAuditService.onRejectionReasonUpdated(bookingId, getActorUserId(actor), data); - } catch (error) { - this.log.error("Error while creating rejection reason updated audit", safeStringify(error)); - } - } - - async onAssignmentReasonUpdated(bookingId: string, actor: Actor, data: AssignmentAuditData) { - try { - await this.bookingAuditService.onAssignmentReasonUpdated(bookingId, getActorUserId(actor), data); - } catch (error) { - this.log.error("Error while creating assignment reason updated audit", safeStringify(error)); - } - } - - async onReassignmentReasonUpdated(bookingId: string, actor: Actor, data: ReassignmentAuditData) { - try { - await this.bookingAuditService.onReassignmentReasonUpdated(bookingId, getActorUserId(actor), data); - } catch (error) { - this.log.error("Error while creating reassignment reason updated audit", safeStringify(error)); + this.log.error("Error while creating reassignment audit", safeStringify(error)); } } @@ -268,14 +247,6 @@ export class BookingEventHandlerService { } } - async onMeetingUrlUpdated(bookingId: string, actor: Actor, data: MeetingUrlUpdatedAuditData) { - try { - await this.bookingAuditService.onMeetingUrlUpdated(bookingId, getActorUserId(actor), data); - } catch (error) { - this.log.error("Error while creating meeting URL updated audit", safeStringify(error)); - } - } - async onAttendeeNoShowUpdated(bookingId: string, actor: Actor, data: AttendeeNoShowUpdatedAuditData) { try { await this.bookingAuditService.onAttendeeNoShowUpdated(bookingId, getActorUserId(actor), data); diff --git a/packages/features/bookings/lib/onBookingEvents/types.d.ts b/packages/features/bookings/lib/onBookingEvents/types.d.ts index 4626a399269c45..7f5deb23cefd96 100644 --- a/packages/features/bookings/lib/onBookingEvents/types.d.ts +++ b/packages/features/bookings/lib/onBookingEvents/types.d.ts @@ -1,3 +1,5 @@ +import type { BookingStatus } from "@calcom/prisma/enums"; + import type { BookingFlowConfig } from "./dto/types"; import type { BookingStatus } from "@calcom/prisma/enums"; @@ -18,5 +20,9 @@ export interface BookingCreatedPayload { }; } -// Add more fields here when needed -type BookingRescheduledPayload = BookingCreatedPayload; +export interface BookingRescheduledPayload extends BookingCreatedPayload { + oldBooking?: { + startTime: Date; + endTime: Date; + }; +} diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 74856380d7d302..efec8a72b7b48f 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -34,6 +34,7 @@ import { handlePayment } from "@calcom/features/bookings/lib/handlePayment"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; +import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d"; import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; @@ -412,6 +413,43 @@ function formatAvailabilitySnapshot(data: { }; } +function buildBookingCreatedPayload({ + booking, + organizerUserId, + hashedLink, + isDryRun, +}: { + booking: { + id: number; + startTime: Date; + endTime: Date; + status: BookingStatus; + userId: number | null; + }; + organizerUserId: number; + hashedLink: string | null; + isDryRun: boolean; +}) { + return { + config: { + isDryRun, + }, + bookingFormData: { + hashedLink, + }, + booking: { + id: booking.id, + startTime: booking.startTime, + endTime: booking.endTime, + status: booking.status, + userId: booking.userId, + user: { + id: organizerUserId, + }, + }, + }; +} + export interface IBookingServiceDependencies { cacheService: CacheService; checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService; @@ -646,6 +684,19 @@ async function handler( const firstPayment = shouldShowPaymentForm ? existingBooking.payment[0] : undefined; + // Create audit log for the existing booking if it doesn't already have one + // This handles the case where a booking was created but audit log creation failed + const bookingCreatedPayload = buildBookingCreatedPayload({ + booking: existingBooking, + organizerUserId: existingBooking.userId ?? existingBooking.user?.id ?? 0, + hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null, + isDryRun, + }); + + // Call onBookingCreated to ensure audit log exists + // This is idempotent - if audit log already exists, it will be handled gracefully + await deps.bookingEventHandler.onBookingCreated(bookingCreatedPayload); + const bookingResponse = { ...existingBooking, user: { @@ -2223,30 +2274,21 @@ async function handler( } : undefined; - const bookingFlowConfig = { + const bookingCreatedPayload = buildBookingCreatedPayload({ + booking, + organizerUserId: organizerUser.id, + hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null, isDryRun, - }; - - const bookingCreatedPayload = { - config: bookingFlowConfig, - bookingFormData: { - // FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely - hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null, - }, - booking: { - id: booking.id, - startTime: booking.startTime, - endTime: booking.endTime, - status: booking.status, - userId: booking.userId, - user: { - id: organizerUser.id, - }, - } - }; + }); // Add more fields here when needed - const bookingRescheduledPayload = bookingCreatedPayload; + const bookingRescheduledPayload: BookingRescheduledPayload = { + ...bookingCreatedPayload, + oldBooking: originalRescheduledBooking ? { + startTime: originalRescheduledBooking.startTime, + endTime: originalRescheduledBooking.endTime, + } : undefined, + }; // TODO: Incrementally move all stuff that happens after a booking is created to these handlers if (originalRescheduledBooking) { diff --git a/packages/features/di/shared/services/logger.service.ts b/packages/features/di/shared/services/logger.service.ts index 68abe17367d608..41d8eda77fe2f4 100644 --- a/packages/features/di/shared/services/logger.service.ts +++ b/packages/features/di/shared/services/logger.service.ts @@ -1,13 +1,39 @@ -import { createModule } from "@evyweb/ioctopus"; - -import type { ILogger } from "@calcom/features/webhooks/lib/interface/infrastructure"; - +import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; +import { Logger } from "tslog"; import { SHARED_TOKENS } from "../shared.tokens"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; -export const loggerServiceModule = createModule(); - -// Bind logger with proper factory that respects IoC -loggerServiceModule.bind(SHARED_TOKENS.LOGGER).toFactory(async (): Promise => { - const loggerModule = await import("@calcom/lib/logger"); - return loggerModule.default; +const thisModule = createModule(); +const token = SHARED_TOKENS.LOGGER; +const moduleToken = SHARED_TOKENS.LOGGER_MODULE; +class LoggerService extends Logger { + constructor() { + super({ + minLevel: 0, + maskValuesOfKeys: ["password", "passwordConfirmation", "credentials", "credential"], + prettyLogTimeZone: IS_PRODUCTION ? "UTC" : "local", + prettyErrorStackTemplate: " • {{fileName}}\t{{method}}\n\t{{filePathWithLine}}", // default + prettyErrorTemplate: "\n{{errorName}} {{errorMessage}}\nerror stack:\n{{errorStack}}", // default + prettyLogTemplate: "{{hh}}:{{MM}}:{{ss}}:{{ms}} [{{logLevelName}}] ", // default with exclusion of `{{filePathWithLine}}` + stylePrettyLogs: !IS_PRODUCTION, + prettyLogStyles: { + name: "yellow", + dateIsoStr: "blue", + }, + type: IS_PRODUCTION ? "json" : "pretty", + }); + } +} +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: LoggerService, + depsMap: {}, }); + +export const moduleLoader = { + token, + loadModule, +}; + diff --git a/packages/features/di/shared/shared.tokens.ts b/packages/features/di/shared/shared.tokens.ts index e0c49da3ef3847..99c22a67495a57 100644 --- a/packages/features/di/shared/shared.tokens.ts +++ b/packages/features/di/shared/shared.tokens.ts @@ -2,4 +2,5 @@ export const SHARED_TOKENS = { // Infrastructure services TASKER: Symbol("ITasker"), LOGGER: Symbol("ILogger"), + LOGGER_MODULE: Symbol("ILoggerModule"), } as const; diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index 1e56074e12a35a..cbb5cfdf116237 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -1,4 +1,5 @@ import { BOOKING_DI_TOKENS } from "@calcom/features/bookings/di/tokens"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; import { HASHED_LINK_DI_TOKENS } from "@calcom/features/hashedLink/di/tokens"; import { WATCHLIST_DI_TOKENS } from "./watchlist/Watchlist.tokens"; @@ -58,6 +59,8 @@ export const DI_TOKENS = { ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"), MEMBERSHIP_SERVICE: Symbol("MembershipService"), MEMBERSHIP_SERVICE_MODULE: Symbol("MembershipServiceModule"), + // Booking audit service tokens + ...BOOKING_AUDIT_DI_TOKENS, // Booking service tokens ...BOOKING_DI_TOKENS, ...HASHED_LINK_DI_TOKENS, diff --git a/packages/features/di/watchlist/containers/watchlist.ts b/packages/features/di/watchlist/containers/watchlist.ts index 718824662d0f5a..55f2a4b6d37419 100644 --- a/packages/features/di/watchlist/containers/watchlist.ts +++ b/packages/features/di/watchlist/containers/watchlist.ts @@ -1,6 +1,6 @@ import { createContainer } from "@evyweb/ioctopus"; -import { loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service"; import { taskerServiceModule } from "@calcom/features/di/shared/services/tasker.service"; import { SHARED_TOKENS } from "@calcom/features/di/shared/shared.tokens"; import { @@ -21,8 +21,8 @@ import { watchlistModule } from "../modules/Watchlist.module"; export const watchlistContainer = createContainer(); prismaModuleLoader.loadModule(watchlistContainer); +loggerModuleLoader.loadModule(watchlistContainer); -watchlistContainer.load(SHARED_TOKENS.LOGGER, loggerServiceModule); watchlistContainer.load(SHARED_TOKENS.TASKER, taskerServiceModule); watchlistContainer.load(WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY, watchlistModule); diff --git a/packages/features/di/webhooks/containers/webhook.ts b/packages/features/di/webhooks/containers/webhook.ts index e791be15c2f9be..70706357962036 100644 --- a/packages/features/di/webhooks/containers/webhook.ts +++ b/packages/features/di/webhooks/containers/webhook.ts @@ -1,6 +1,6 @@ import { createContainer } from "@evyweb/ioctopus"; -import { loggerServiceModule } from "../../shared/services/logger.service"; +import { moduleLoader as loggerModuleLoader } from "../../shared/services/logger.service"; import { taskerServiceModule } from "../../shared/services/tasker.service"; import { SHARED_TOKENS } from "../../shared/shared.tokens"; import { WEBHOOK_TOKENS } from "../Webhooks.tokens"; @@ -9,7 +9,7 @@ import { webhookModule } from "../modules/Webhook.module"; export const webhookContainer = createContainer(); // Load shared infrastructure -webhookContainer.load(SHARED_TOKENS.LOGGER, loggerServiceModule); +loggerModuleLoader.loadModule(webhookContainer); webhookContainer.load(SHARED_TOKENS.TASKER, taskerServiceModule); // Load webhook module diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 99e96fd5410bc9..ec2da45e41eb61 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -10,11 +10,14 @@ import { sendRoundRobinScheduledEmailsAndSMS, sendRoundRobinUpdatedEmailsAndSMS, } from "@calcom/emails"; +import type { ReassignmentAuditData } from "@calcom/features/booking-audit/lib/actions/ReassignmentAuditActionService"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; +import { createUserActor } from "@calcom/features/bookings/lib/types/actor"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import AssignmentReasonRecorder, { RRReassignmentType } from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService"; @@ -102,14 +105,14 @@ export const roundRobinManualReassignment = async ({ const eventTypeHosts = eventType.hosts.length ? eventType.hosts : eventType.users.map((user) => ({ - user, - isFixed: false, - priority: 2, - weight: 100, - schedule: null, - createdAt: new Date(0), // use earliest possible date as fallback - groupId: null, - })); + user, + isFixed: false, + priority: 2, + weight: 100, + schedule: null, + createdAt: new Date(0), // use earliest possible date as fallback + groupId: null, + })); const fixedHost = eventTypeHosts.find((host) => host.isFixed); const currentRRHost = booking.attendees.find((attendee) => @@ -199,6 +202,11 @@ export const roundRobinManualReassignment = async ({ t: newUserT, }); + const oldUserId = booking.userId; + const _oldUser = booking.user; + const oldEmail = booking.user?.email; + const _oldTitle = booking.title; + booking = await prisma.booking.update({ where: { id: bookingId }, data: { @@ -211,12 +219,30 @@ export const roundRobinManualReassignment = async ({ startTime: booking.startTime, endTime: booking.endTime, userId: newUser.id, - reassignedById, + reassignedById: reassignedById, }), }, select: bookingSelect, }); + try { + const bookingEventHandlerService = getBookingEventHandlerService(); + const auditData: ReassignmentAuditData = { + assignedToId: { old: oldUserId ?? null, new: newUserId }, + assignedById: { old: null, new: reassignedById }, + reassignmentReason: { old: null, new: reassignReason || "Manual round robin reassignment" }, + userPrimaryEmail: { old: oldEmail || null, new: newUser.email }, + title: { old: _oldTitle, new: newBookingTitle }, + }; + await bookingEventHandlerService.onReassignment( + String(bookingId), + createUserActor(reassignedById), + auditData + ); + } catch (error) { + logger.error("Failed to create booking audit log for manual round robin reassignment", error); + } + await AssignmentReasonRecorder.roundRobinReassignment({ bookingId, reassignReason, @@ -331,8 +357,8 @@ export const roundRobinManualReassignment = async ({ const previousHostDestinationCalendar = hasOrganizerChanged ? await prisma.destinationCalendar.findFirst({ - where: { userId: originalOrganizer.id }, - }) + where: { userId: originalOrganizer.id }, + }) : null; const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); @@ -574,22 +600,22 @@ export async function handleWorkflowsUpdate({ }, ...(eventType?.teamId ? [ - { - activeOnTeams: { - some: { - teamId: eventType.teamId, - }, + { + activeOnTeams: { + some: { + teamId: eventType.teamId, }, }, - ] + }, + ] : []), ...(eventType?.team?.parentId ? [ - { - isActiveOnAll: true, - teamId: eventType.team.parentId, - }, - ] + { + isActiveOnAll: true, + teamId: eventType.team.parentId, + }, + ] : []), ], }, diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 1c60f8a95f84ad..a819b4dce1a327 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -14,6 +14,7 @@ import { sendRoundRobinScheduledEmailsAndSMS, sendRoundRobinUpdatedEmailsAndSMS, } from "@calcom/emails"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; @@ -21,6 +22,7 @@ import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventR import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers"; import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types"; +import { createUserActor } from "@calcom/features/bookings/lib/types/actor"; import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser"; import AssignmentReasonRecorder, { RRReassignmentType, @@ -103,14 +105,14 @@ export const roundRobinReassignment = async ({ eventType.hosts = eventType.hosts.length ? eventType.hosts : eventType.users.map((user) => ({ - user, - isFixed: false, - priority: 2, - weight: 100, - schedule: null, - createdAt: new Date(0), // use earliest possible date as fallback - groupId: null, - })); + user, + isFixed: false, + priority: 2, + weight: 100, + schedule: null, + createdAt: new Date(0), // use earliest possible date as fallback + groupId: null, + })); if (eventType.hosts.length === 0) { throw new Error(ErrorCode.EventTypeNoHosts); @@ -248,6 +250,10 @@ export const roundRobinReassignment = async ({ newBookingTitle = getEventName(eventNameObject); + const oldUserId = booking.userId; + const oldEmail = booking.user?.email || ""; + const oldTitle = booking.title; + booking = await prisma.booking.update({ where: { id: bookingId, @@ -261,11 +267,28 @@ export const roundRobinReassignment = async ({ startTime: booking.startTime, endTime: booking.endTime, userId: reassignedRRHost.id, - reassignedById, + reassignedById: reassignedById, }), }, select: bookingSelect, }); + + try { + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onReassignment( + String(bookingId), + createUserActor(reassignedById), + { + assignedToId: { old: oldUserId, new: reassignedRRHost.id }, + assignedById: { old: null, new: reassignedById }, + reassignmentReason: { old: null, new: "Round robin reassignment" }, + userPrimaryEmail: { old: oldEmail, new: reassignedRRHost.email }, + title: { old: oldTitle, new: newBookingTitle }, + } + ); + } catch (error) { + logger.error("Failed to create booking audit log for round robin reassignment", error); + } } else { const previousRRHostAttendee = booking.attendees.find( (attendee) => attendee.email === previousRRHost.email @@ -301,10 +324,10 @@ export const roundRobinReassignment = async ({ // If changed owner, also change destination calendar const previousHostDestinationCalendar = hasOrganizerChanged ? await prisma.destinationCalendar.findFirst({ - where: { - userId: originalOrganizer.id, - }, - }) + where: { + userId: originalOrganizer.id, + }, + }) : null; const evt: CalendarEvent = { @@ -343,12 +366,6 @@ export const roundRobinReassignment = async ({ ...(platformClientParams ? platformClientParams : {}), }; - if (hasOrganizerChanged) { - // location might changed and will be new created in eventManager.create (organizer default location) - evt.videoCallData = undefined; - // To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID - evt.iCalUID = undefined; - } const credentials = await prisma.credential.findMany({ where: { userId: organizer.id, diff --git a/packages/features/handleMarkNoShow.ts b/packages/features/handleMarkNoShow.ts index 177ffdceb8abc3..edae1962426db2 100644 --- a/packages/features/handleMarkNoShow.ts +++ b/packages/features/handleMarkNoShow.ts @@ -1,5 +1,7 @@ import { type TFunction } from "i18next"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; +import { createUserActor } from "@calcom/features/bookings/lib/types/actor"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows"; @@ -44,7 +46,7 @@ const buildResultPayload = async ( }; }; -const logFailedResults = (results: PromiseSettledResult[]) => { +const logFailedResults = (results: PromiseSettledResult[]) => { const failed = results.filter((x) => x.status === "rejected") as PromiseRejectedResult[]; if (failed.length < 1) return; const failedMessage = failed.map((r) => r.reason); @@ -104,6 +106,18 @@ const handleMarkNoShow = async ({ if (attendees && attendeeEmails.length > 0) { await assertCanAccessBooking(bookingUid, userId); + // Get old noShow values before updating for audit log + const oldAttendeeValues = await prisma.attendee.findMany({ + where: { + booking: { uid: bookingUid }, + email: { in: attendeeEmails }, + }, + select: { + email: true, + noShow: true, + }, + }); + const payload = await buildResultPayload(bookingUid, attendeeEmails, attendees, t); const { webhooks, bookingId } = await getWebhooksService( @@ -247,14 +261,14 @@ const handleMarkNoShow = async ({ const destinationCalendar = booking.destinationCalendar ? [booking.destinationCalendar] : booking.user?.destinationCalendar - ? [booking.user?.destinationCalendar] - : []; + ? [booking.user?.destinationCalendar] + : []; const team = booking.eventType?.team ? { - name: booking.eventType.team.name, - id: booking.eventType.team.id, - members: [], - } + name: booking.eventType.team.name, + id: booking.eventType.team.id, + members: [], + } : undefined; const calendarEvent: ExtendedCalendarEvent = { @@ -309,10 +323,43 @@ const handleMarkNoShow = async ({ responsePayload.setAttendees(payload.attendees); responsePayload.setMessage(payload.message); + // Create audit log for attendee no-show updates + if (userId && payload.attendees.length > 0) { + try { + const booking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { id: true }, + }); + + if (booking) { + const bookingEventHandlerService = getBookingEventHandlerService(); + + // Track if any attendee was marked as no-show + const anyOldNoShow = oldAttendeeValues.some((a) => a.noShow); + const anyNewNoShow = payload.attendees.some((a) => a.noShow); + + await bookingEventHandlerService.onAttendeeNoShowUpdated( + String(booking.id), + createUserActor(userId), + { + noShowAttendee: { old: anyOldNoShow, new: anyNewNoShow }, + } + ); + } + } catch (error) { + logger.error("Failed to create booking audit log for attendee no-show", error); + } + } + await handleSendingAttendeeNoShowDataToApps(bookingUid, attendees); } if (noShowHost) { + const bookingToUpdate = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { id: true, noShowHost: true }, + }); + await prisma.booking.update({ where: { uid: bookingUid, @@ -322,6 +369,24 @@ const handleMarkNoShow = async ({ }, }); + if (userId && bookingToUpdate) { + try { + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onHostNoShowUpdated( + String(bookingToUpdate.id), + createUserActor(userId), + { + noShowHost: { + old: bookingToUpdate.noShowHost, + new: true, + }, + } + ); + } catch (error) { + logger.error("Failed to create booking audit log for host no-show", error); + } + } + responsePayload.setNoShowHost(true); responsePayload.setMessage(t("booking_no_show_updated")); } diff --git a/packages/features/shell/navigation/NavigationItem.tsx b/packages/features/shell/navigation/NavigationItem.tsx index 76a0e0bed404ad..34a93b2f677dca 100644 --- a/packages/features/shell/navigation/NavigationItem.tsx +++ b/packages/features/shell/navigation/NavigationItem.tsx @@ -46,7 +46,7 @@ const useBuildHref = () => { childItem.preserveQueryParams && childItem.preserveQueryParams({ prevPathname: prevPathnameRef.current, nextPathname: childItem.href }) ) { - const params = searchParams.toString(); + const params = searchParams?.toString(); return params ? `${childItem.href}?${params}` : childItem.href; } return childItem.href; diff --git a/packages/lib/logger.ts b/packages/lib/logger.ts index 5d60e8dd782740..baa70d2c14767f 100644 --- a/packages/lib/logger.ts +++ b/packages/lib/logger.ts @@ -3,7 +3,7 @@ import { Logger } from "tslog"; import { IS_PRODUCTION } from "./constants"; const logger = new Logger({ - minLevel: parseInt(process.env.NEXT_PUBLIC_LOGGER_LEVEL || "4"), + minLevel: 0, maskValuesOfKeys: ["password", "passwordConfirmation", "credentials", "credential"], prettyLogTimeZone: IS_PRODUCTION ? "UTC" : "local", prettyErrorStackTemplate: " • {{fileName}}\t{{method}}\n\t{{filePathWithLine}}", // default diff --git a/packages/prisma/migrations/20251103053718_asdf/migration.sql b/packages/prisma/migrations/20251103053718_asdf/migration.sql new file mode 100644 index 00000000000000..682cd747b68630 --- /dev/null +++ b/packages/prisma/migrations/20251103053718_asdf/migration.sql @@ -0,0 +1,49 @@ +/* + Warnings: + + - The values [reassignment_reason_updated] on the enum `BookingAuditAction` will be removed. If these variants are still used in the database, this will fail. + - A unique constraint covering the columns `[attendeeId]` on the table `Actor` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[email]` on the table `Actor` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[phone]` on the table `Actor` will be added. If there are existing duplicate values, this will fail. + - Added the required column `updatedAt` to the `BookingAudit` table without a default value. This is not possible if the table is not empty. + - Made the column `action` on table `BookingAudit` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterEnum +ALTER TYPE "public"."ActorType" ADD VALUE 'attendee'; + +-- AlterEnum +BEGIN; +CREATE TYPE "public"."BookingAuditAction_new" AS ENUM ('created', 'cancelled', 'accepted', 'rejected', 'pending', 'awaiting_host', 'rescheduled', 'attendee_added', 'attendee_removed', 'cancellation_reason_updated', 'rejection_reason_updated', 'assignment_reason_updated', 'reassignment', 'location_changed', 'meeting_url_updated', 'host_no_show_updated', 'attendee_no_show_updated', 'reschedule_requested'); +ALTER TABLE "public"."BookingAudit" ALTER COLUMN "action" TYPE "public"."BookingAuditAction_new" USING ("action"::text::"public"."BookingAuditAction_new"); +ALTER TYPE "public"."BookingAuditAction" RENAME TO "BookingAuditAction_old"; +ALTER TYPE "public"."BookingAuditAction_new" RENAME TO "BookingAuditAction"; +DROP TYPE "public"."BookingAuditAction_old"; +COMMIT; + +-- AlterTable +ALTER TABLE "public"."Actor" ADD COLUMN "attendeeId" INTEGER; + +-- AlterTable +ALTER TABLE "public"."BookingAudit" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL, +ALTER COLUMN "action" SET NOT NULL, +ALTER COLUMN "timestamp" DROP DEFAULT; + +-- CreateIndex +CREATE INDEX "Actor_attendeeId_idx" ON "public"."Actor"("attendeeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Actor_attendeeId_key" ON "public"."Actor"("attendeeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Actor_email_key" ON "public"."Actor"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Actor_phone_key" ON "public"."Actor"("phone"); + +-- CreateIndex +CREATE INDEX "BookingAudit_timestamp_idx" ON "public"."BookingAudit"("timestamp"); + +-- AddForeignKey +ALTER TABLE "public"."Actor" ADD CONSTRAINT "Actor_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "public"."Attendee"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20251103054238_dsaf/migration.sql b/packages/prisma/migrations/20251103054238_dsaf/migration.sql new file mode 100644 index 00000000000000..c6269af82c13fe --- /dev/null +++ b/packages/prisma/migrations/20251103054238_dsaf/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [pending,awaiting_host,cancellation_reason_updated,rejection_reason_updated,assignment_reason_updated,meeting_url_updated] on the enum `BookingAuditAction` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "public"."BookingAuditAction_new" AS ENUM ('created', 'cancelled', 'accepted', 'rejected', 'rescheduled', 'attendee_added', 'attendee_removed', 'reassignment', 'location_changed', 'host_no_show_updated', 'attendee_no_show_updated', 'reschedule_requested'); +ALTER TABLE "public"."BookingAudit" ALTER COLUMN "action" TYPE "public"."BookingAuditAction_new" USING ("action"::text::"public"."BookingAuditAction_new"); +ALTER TYPE "public"."BookingAuditAction" RENAME TO "BookingAuditAction_old"; +ALTER TYPE "public"."BookingAuditAction_new" RENAME TO "BookingAuditAction"; +DROP TYPE "public"."BookingAuditAction_old"; +COMMIT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index b98e6d374ae63f..46b47ec355452b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2616,26 +2616,20 @@ enum BookingAuditAction { 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") + CANCELLED @map("cancelled") + ACCEPTED @map("accepted") + REJECTED @map("rejected") + RESCHEDULED @map("rescheduled") // Attendee management ATTENDEE_ADDED @map("attendee_added") ATTENDEE_REMOVED @map("attendee_removed") - // Cancellation/Rejection/Assignment reasons - CANCELLATION_REASON_UPDATED @map("cancellation_reason_updated") - REJECTION_REASON_UPDATED @map("rejection_reason_updated") - ASSIGNMENT_REASON_UPDATED @map("assignment_reason_updated") - REASSIGNMENT_REASON_UPDATED @map("reassignment_reason_updated") + // Assignment/Reassignment + REASSIGNMENT @map("reassignment") // Meeting details - LOCATION_CHANGED @map("location_changed") - MEETING_URL_UPDATED @map("meeting_url_updated") + LOCATION_CHANGED @map("location_changed") // No-show tracking HOST_NO_SHOW_UPDATED @map("host_no_show_updated") diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 6a6bfb6254b67f..725e3a89baf3cd 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -7,18 +7,20 @@ import { ZEditLocationInputSchema } from "./editLocation.schema"; import { ZFindInputSchema } from "./find.schema"; import { ZGetInputSchema } from "./get.schema"; import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema"; +import { ZGetAuditLogsInputSchema } from "./getAuditLogs.schema"; import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema"; import { ZReportBookingInputSchema } from "./reportBooking.schema"; import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema"; import { bookingsProcedure } from "./util"; -type BookingsRouterHandlerCache = { +type _BookingsRouterHandlerCache = { get?: typeof import("./get.handler").getHandler; requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler; editLocation?: typeof import("./editLocation.handler").editLocationHandler; addGuests?: typeof import("./addGuests.handler").addGuestsHandler; confirm?: typeof import("./confirm.handler").confirmHandler; getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler; + getAuditLogs?: typeof import("./getAuditLogs.handler").getAuditLogsHandler; find?: typeof import("./find.handler").getHandler; getInstantBookingLocation?: typeof import("./getInstantBookingLocation.handler").getHandler; reportBooking?: typeof import("./reportBooking.handler").reportBookingHandler; @@ -81,6 +83,15 @@ export const bookingsRouter = router({ }); }), + getAuditLogs: authedProcedure.input(ZGetAuditLogsInputSchema).query(async ({ input, ctx }) => { + const { getAuditLogsHandler } = await import("./getAuditLogs.handler"); + + return getAuditLogsHandler({ + ctx, + input, + }); + }), + find: publicProcedure.input(ZFindInputSchema).query(async ({ input, ctx }) => { const { getHandler } = await import("./find.handler"); diff --git a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts index e6e758d29b2151..c8de7df57c5521 100644 --- a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts @@ -19,6 +19,7 @@ import type { CalendarEvent } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../types"; +import { checkEmailVerificationRequired } from "../../publicViewer/checkIfUserEmailVerificationRequired.handler"; import type { TAddGuestsInputSchema } from "./addGuests.schema"; type TUser = Pick, "id" | "email" | "organizationId"> & @@ -286,8 +287,8 @@ async function buildCalendarEvent( destinationCalendar: booking?.destinationCalendar ? [booking?.destinationCalendar] : booking?.user?.destinationCalendar - ? [booking?.user?.destinationCalendar] - : [], + ? [booking?.user?.destinationCalendar] + : [], seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, seatsShowAttendees: booking.eventType?.seatsShowAttendees, customReplyToEmail: booking.eventType?.customReplyToEmail, diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index 94a61f29d344be..37dd46c99c3e90 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -5,6 +5,7 @@ import { getEventLocationType, OrganizerDefaultConferencingAppType } from "@calc import { getAppFromSlug } from "@calcom/app-store/utils"; import { sendLocationChangeEmailsAndSMS } from "@calcom/emails"; import EventManager from "@calcom/features/bookings/lib/EventManager"; +import { createUserActor } from "@calcom/features/bookings/lib/types/actor"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; @@ -23,6 +24,8 @@ import type { Ensure } from "@calcom/types/utils"; import { TRPCError } from "@trpc/server"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; + import type { TrpcSessionUser } from "../../../types"; import type { TEditLocationInputSchema } from "./editLocation.schema"; import type { BookingsProcedureContext } from "./util"; @@ -249,6 +252,8 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) { const { newLocation, credentialId: conferenceCredentialId } = input; const { booking, user: loggedInUser } = ctx; + const oldLocation = booking.location; + const organizer = await new UserRepository(prisma).findByIdOrThrow({ id: booking.userId || 0 }); const newLocationInEvtFormat = await getLocationInEvtFormatOrThrow({ @@ -292,5 +297,22 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) { console.log("Error sending LocationChangeEmails", safeStringify(error)); } + // Create audit log for location change + try { + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onLocationChanged( + String(booking.id), + createUserActor(loggedInUser.id), + { + location: { + old: oldLocation, + new: newLocationInEvtFormat, + }, + } + ); + } catch (error) { + logger.error("Failed to create booking audit log for location change", error); + } + return { message: "Location updated" }; } diff --git a/packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts new file mode 100644 index 00000000000000..3c4836c26ab37d --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts @@ -0,0 +1,151 @@ +import type { PrismaClient } from "@calcom/prisma/client"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TGetAuditLogsInputSchema } from "./getAuditLogs.schema"; + +type GetAuditLogsOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetAuditLogsInputSchema; +}; + +export const getAuditLogsHandler = async ({ ctx, input }: GetAuditLogsOptions) => { + const { prisma, user } = ctx; + const { bookingUid } = input; + + // First, get the booking to verify permissions + const booking = await prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + select: { + id: true, + userId: true, + eventTypeId: true, + attendees: { + select: { + email: true, + }, + }, + }, + }); + + if (!booking) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Booking not found", + }); + } + + // Check if user has permission to view this booking's audit logs + const isBookingOwner = booking.userId === user.id; + const isAttendee = booking.attendees.some((attendee) => attendee.email === user.email); + + // Check if user is team admin/owner for the event type + let isTeamAdminOrOwner = false; + if (booking.eventTypeId) { + const eventType = await prisma.eventType.findUnique({ + where: { id: booking.eventTypeId }, + select: { + teamId: true, + team: { + select: { + members: { + where: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + select: { + id: true, + }, + }, + }, + }, + }, + }); + + if (eventType?.team?.members && eventType.team.members.length > 0) { + isTeamAdminOrOwner = true; + } + } + + if (!isBookingOwner && !isAttendee && !isTeamAdminOrOwner) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to view audit logs for this booking", + }); + } + + // Fetch audit logs with actor information + const auditLogs = await prisma.bookingAudit.findMany({ + where: { + bookingId: booking.id.toString(), + }, + include: { + actor: { + select: { + id: true, + type: true, + userId: true, + attendeeId: true, + email: true, + phone: true, + name: true, + createdAt: true, + }, + }, + }, + orderBy: { + timestamp: "desc", + }, + }); + + // Enrich actor information with user details if userId exists + const enrichedAuditLogs = await Promise.all( + auditLogs.map(async (log) => { + let actorDisplayName = log.actor.name || "System"; + let actorEmail = log.actor.email; + + if (log.actor.userId) { + const actorUser = await prisma.user.findUnique({ + where: { id: log.actor.userId }, + select: { + name: true, + email: true, + }, + }); + + if (actorUser) { + actorDisplayName = actorUser.name || actorUser.email; + actorEmail = actorUser.email; + } + } + + return { + id: log.id, + bookingId: log.bookingId, + type: log.type, + action: log.action, + timestamp: log.timestamp.toISOString(), + createdAt: log.createdAt.toISOString(), + data: log.data, + actor: { + ...log.actor, + displayName: actorDisplayName, + displayEmail: actorEmail, + }, + }; + }) + ); + + return { + bookingUid, + auditLogs: enrichedAuditLogs, + }; +}; + diff --git a/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts new file mode 100644 index 00000000000000..9ed51a0328afc1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetAuditLogsInputSchema = z.object({ + bookingUid: z.string(), +}); + +export type TGetAuditLogsInputSchema = z.infer; + diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 0168c20931b575..5b98cc573d76fd 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -5,7 +5,10 @@ import { getDelegationCredentialOrRegularCredential } from "@calcom/app-store/de import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential"; import dayjs from "@calcom/dayjs"; import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails"; +import type { RescheduleRequestedAuditData } from "@calcom/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { createUserActor } from "@calcom/features/bookings/lib/types/actor"; import { deleteMeeting } from "@calcom/features/conferencing/lib/videoClient"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; @@ -80,6 +83,9 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule workflowReminders: true, responses: true, iCalUID: true, + cancellationReason: true, + rescheduled: true, + cancelledBy: true, }, where: { uid: bookingId, @@ -154,6 +160,31 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule }, }); + try { + const bookingEventHandlerService = getBookingEventHandlerService(); + const auditData: RescheduleRequestedAuditData = { + cancellationReason: { + old: bookingToReschedule.cancellationReason, + new: cancellationReason ?? null, + }, + cancelledBy: { + old: bookingToReschedule.cancelledBy, + new: user.email, + }, + rescheduled: { + old: bookingToReschedule.rescheduled ?? false, + new: true, + }, + }; + await bookingEventHandlerService.onRescheduleRequested( + String(bookingToReschedule.id), + createUserActor(user.id), + auditData + ); + } catch (error) { + log.error("Failed to create booking audit log for reschedule request", error); + } + // delete scheduled jobs of previous booking const webhookPromises = []; webhookPromises.push(deleteWebhookScheduledTriggers({ booking: bookingToReschedule })); @@ -210,17 +241,19 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule customReplyToEmail: bookingToReschedule.eventType?.customReplyToEmail, team: bookingToReschedule.eventType?.team ? { - name: bookingToReschedule.eventType.team.name, - id: bookingToReschedule.eventType.team.id, - members: [], - } + name: bookingToReschedule.eventType.team.name, + id: bookingToReschedule.eventType.team.id, + members: [], + } : undefined, }); const director = new CalendarEventDirector(); director.setBuilder(builder); director.setExistingBooking(bookingToReschedule); - cancellationReason && director.setCancellationReason(cancellationReason); + if (cancellationReason) { + director.setCancellationReason(cancellationReason); + } if (Object.keys(event).length) { // Request Reschedule flow first cancels the booking and then reschedule email is sent. So, we need to allow reschedule for cancelled booking await director.buildForRescheduleEmail({ allowRescheduleForCancelledBooking: true }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts index ddfdebf7f80b48..56eaababda3225 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { prisma } from "@calcom/prisma"; -import type { Team, User, Membership, Profile } from "@calcom/prisma/client"; +import type { Team, User, Membership, Profile, Prisma } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -91,7 +91,7 @@ async function createTestTeam(data: { slug: uniqueSlug, isOrganization: data.isOrganization || false, parentId: data.parentId, - metadata: data.metadata, + metadata: data.metadata as Prisma.InputJsonValue, }, });