chore: Improves the core booking audit architecture by adding new capabilities and simplifying the existing interfaces. (#25872)

* feat(booking-audit): extract core audit system changes from PR 25125

This PR extracts core audit infrastructure changes without integration changes:

1. New action services introduced:
   - SeatBookedAuditActionService
   - SeatRescheduledAuditActionService

2. Simplification of ActionService interface:
   - Streamlined IAuditActionService interface
   - Reduced TypeScript burden with cleaner type definitions

3. ActionSource support:
   - Added BookingAuditSource enum (API_V1, API_V2, WEBAPP, WEBHOOK, UNKNOWN)
   - Added source and operationId fields to BookingAudit model

4. New AuditAction types:
   - SEAT_BOOKED
   - SEAT_RESCHEDULED
   - APP actor type

5. New BookingAuditAccessService:
   - Permission-based access control for audit logs
   - Added readTeamAuditLogs and readOrgAuditLogs permissions

6. Fixes in the logs viewer flow:
   - Enhanced BookingAuditViewerService with improved filtering
   - Local AttendeeRepository for actor enrichment

Changes are contained within packages/features/booking-audit with minimal
outside changes (permission registry only).

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor(booking-audit): streamline action handling and enhance localization

- Replaced action icon retrieval with a mapping object for improved clarity and performance.
- Introduced constants for actor role labels to simplify role retrieval.
- Added new localization strings for audit log permission errors and organization requirements.
- Updated various service and repository interfaces to enhance type safety and clarity.
- Removed deprecated architecture documentation and adjusted related imports for consistency.

These changes aim to improve code maintainability and user experience in the booking audit system.

* fix(booking-audit): enhance actor role localization and operation ID tracking

- Updated actor role labels in the booking logs view to use lowercase for consistency.
- Improved localization by wrapping actor role display in a translation function.
- Added operationId field to audit logs for better correlation of actions across multiple bookings.
- Enhanced BookingAuditViewerService to include operationId in enriched audit logs.
- Updated integration tests to verify consistent operationId across related audit logs.

These changes aim to improve localization accuracy and facilitate better tracking of user actions in the booking audit system.

* feat: integrate credential repository and enhance app actor handling

- Added CredentialRepository to manage app credentials, including a method to find credentials by ID.
- Updated BookingAudit system to support app actors identified by credential ID, improving actor attribution and audit clarity.
- Introduced a new utility function to map app slugs to display names, enhancing the user experience in audit logs.
- Modified relevant interfaces and types to accommodate the new credential handling and app actor structure.
- Enhanced BookingAuditViewerService to display app names based on credentials, ensuring accurate representation in audit logs.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2025-12-18 12:46:24 +00:00
committed by GitHub
co-authored by Devin AI
parent 9019be58ed
commit f1011ddd08
57 changed files with 3808 additions and 1701 deletions
@@ -2,7 +2,7 @@ import { TaskerService } from "@/lib/services/tasker.service";
import { Logger } from "@/lib/logger.bridge";
import { Injectable } from "@nestjs/common";
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
import { BookingAuditTaskerProducerService, getAuditActorRepository } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
@@ -10,7 +10,7 @@ export class BookingAuditProducerService extends BookingAuditTaskerProducerServi
super({
tasker: taskerService.getTasker(),
log: bridgeLogger,
auditActorRepository: getAuditActorRepository(),
});
}
}
@@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
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 { Icon, type IconName } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { FilterSearchField, Select } from "@calcom/ui/components/form";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -37,9 +37,10 @@ type AuditLog = {
action: string;
type: string;
timestamp: string;
data: Record<string, unknown> | null;
source: string;
displayJson?: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey: string }>;
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
actor: {
type: AuditActorType;
displayName: string | null;
@@ -51,55 +52,38 @@ type AuditLog = {
interface BookingLogsFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
actorFilter: AuditActorType | null;
onActorFilterChange: (value: AuditActorType | null) => void;
actorOptions: Array<{ label: string; value: AuditActorType }>;
actorFilter: string | null;
onActorFilterChange: (value: string | null) => void;
actorOptions: Array<{ label: string; value: string }>;
}
interface BookingLogsTimelineProps {
logs: AuditLog[];
}
const getActionIcon = (action: string) => {
switch (action) {
case "CREATED":
return "calendar";
case "CANCELLED":
case "REJECTED":
return "ban";
case "ACCEPTED":
return "check";
case "RESCHEDULED":
case "RESCHEDULE_REQUESTED":
return "pencil";
case "REASSIGNMENT":
case "ATTENDEE_ADDED":
case "ATTENDEE_REMOVED":
return "user-check";
case "LOCATION_CHANGED":
return "map-pin";
case "HOST_NO_SHOW_UPDATED":
case "ATTENDEE_NO_SHOW_UPDATED":
return "ban";
default:
return "sparkles";
}
};
const ACTION_ICON_MAP: Record<string, IconName> = {
CREATED: "calendar",
CANCELLED: "ban",
REJECTED: "ban",
ACCEPTED: "check",
RESCHEDULED: "pencil",
RESCHEDULE_REQUESTED: "pencil",
REASSIGNMENT: "user-check",
ATTENDEE_ADDED: "user-check",
ATTENDEE_REMOVED: "user-check",
LOCATION_CHANGED: "map-pin",
HOST_NO_SHOW_UPDATED: "ban",
ATTENDEE_NO_SHOW_UPDATED: "ban",
} as const;
const getActorRoleLabel = (actorType: AuditActorType): string | null => {
switch (actorType) {
case "GUEST":
return "Guest";
case "ATTENDEE":
return "Attendee";
case "SYSTEM":
return null;
case "USER":
return null;
default:
return null;
}
};
const ACTOR_ROLE_LABEL_MAP: Record<AuditActorType, string | null> = {
GUEST: "guest",
ATTENDEE: "attendee",
SYSTEM: null,
USER: null,
APP: null,
} as const;
function BookingLogsFilters({
searchTerm,
@@ -127,9 +111,9 @@ function BookingLogsFilters({
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
onChange={(option) => {
if (!option) return;
onActorFilterChange((option.value as AuditActorType) || null);
onActorFilterChange(option.value || null);
}}
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
options={[{ label: t("all"), value: "" }, ...actorOptions]}
/>
</div>
</div>
@@ -236,14 +220,13 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
const isLast = index === logs.length - 1;
const isExpanded = expandedLogIds.has(log.id);
const showJson = showJsonMap[log.id] || false;
const actorRole = getActorRoleLabel(log.actor.type);
const actorRole = ACTOR_ROLE_LABEL_MAP[log.actor.type] ?? null;
return (
<div key={log.id} className="flex gap-1">
<div className="flex flex-col items-center self-stretch">
<div className="pt-2 shrink-0">
<div className="bg-subtle rounded-[3.556px] p-1 flex items-center justify-center w-4 h-4">
<Icon name={getActionIcon(log.action)} className="h-3 w-3 text-subtle" />
<Icon name={ACTION_ICON_MAP[log.action] ?? "sparkles"} className="h-3 w-3 text-subtle" />
</div>
</div>
{!isLast && (
@@ -268,7 +251,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
)}
<span>
{log.actor.displayName}
{actorRole && ` (${actorRole})`}
{actorRole && <span>{` (${t(actorRole)})`}</span>}
</span>
<span>•</span>
<span>{dayjs(log.timestamp).fromNow()}</span>
@@ -305,6 +288,10 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
</div>
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t("source")}</span>
<span className="text-default">{log.source}</span>
</div>
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">
{t("timestamp")}
@@ -313,7 +300,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
</span>
</div>
{log.data && Object.keys(log.data).length > 0 && (
{log.displayJson && Object.keys(log.displayJson).length > 0 && (
<div>
<div className="flex flex-col items-start gap-2 py-1 px-3 border-b border-subtle">
<Button
@@ -326,7 +313,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
</Button>
</div>
<div>
{showJson && <JsonViewer data={log.data} />}
{showJson && <JsonViewer data={log.displayJson} />}
</div>
</div>
)}
@@ -342,15 +329,55 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
);
}
function useBookingLogsFilters(
auditLogs: AuditLog[],
searchTerm: string,
actorFilter: string | null
) {
const filteredLogs = auditLogs.filter((log) => {
const matchesSearch =
!searchTerm ||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesActor = !actorFilter || log.actor.displayName === actorFilter;
return matchesSearch && matchesActor;
});
const uniqueActorNames = Array.from(
new Set(
auditLogs
.map((log) => log.actor.displayName)
.filter((name): name is string => Boolean(name))
)
);
const actorOptions = uniqueActorNames.map((actorName) => ({
label: actorName,
value: actorName,
}));
return { filteredLogs, actorOptions };
}
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [actorFilter, setActorFilter] = useState<AuditActorType | null>(null);
const [actorFilter, setActorFilter] = useState<string | null>(null);
const { t } = useLocale();
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
bookingUid,
});
const auditLogs = data?.auditLogs || [];
const { filteredLogs, actorOptions } = useBookingLogsFilters(
auditLogs,
searchTerm,
actorFilter
);
if (error) {
return (
<div className="flex items-center justify-center py-12">
@@ -376,27 +403,6 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
);
}
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 matchesActor = !actorFilter || log.actor.type === actorFilter;
return matchesSearch && matchesActor;
});
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))) as AuditActorType[];
const actorOptions = uniqueActorTypes.map((actorType) => ({
label: actorType,
value: actorType,
}));
return (
<div className="space-y-6">
<BookingLogsFilters
@@ -309,6 +309,7 @@
"unconfirmed": "Unconfirmed",
"guests": "Guests",
"guest": "Guest",
"attendee": "Attendee",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"confirmation": "Confirmation",
"cancellation": "Cancellation",
@@ -4163,5 +4164,11 @@
"timestamp": "Timestamp",
"json": "JSON",
"hubspot_ignore_guests": "Do not create new records for guests added to the booking",
"audit_logs_organization_required": "You must be part of an organization to view audit logs.",
"audit_logs_booking_not_found_or_permission_denied": "Booking not found or you do not have permission to view its audit logs.",
"audit_logs_booking_has_no_owner": "Cannot verify permissions: booking has no associated user.",
"audit_logs_owner_not_in_organization": "The booking owner is not a member of your organization.",
"audit_logs_permission_denied": "You do not have permission to view audit logs for this booking.",
"audit_logs_permission_check_error": "An error occurred while checking permissions.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,820 +0,0 @@
# Booking Audit System - Database Architecture
Based on https://github.com/calcom/cal.com/pull/22817
Note: This architecture is not in production yet, so we can make any changes we want to it without worrying about backwards compatibility.
## Overview
The Booking Audit System tracks all actions and changes related to bookings in Cal.com. The architecture is built around two core tables (`AuditActor` and `BookingAudit`) that work together to maintain a complete, immutable audit trail.
## Database Tables
### 1. AuditActor Table
The `AuditActor` table stores information about entities that perform actions on bookings. It maintains historical records even after users are deleted.
```prisma
model AuditActor {
id String @id @default(uuid())
type AuditActorType
// References for different actor types (soft references, no FK constraints)
userUuid String? @db.Uuid // For USER type (nullable to allow user deletion)
attendeeId Int? // For ATTENDEE type(nullable to allow attendee deletion)
// Identity fields - only for GUEST/SYSTEM(System too might not have all) type. Attendee and User maintain their own identity fields.
// They could be set as anonymized for User/Attendee record as well when they are deleted to preserve the audit trail.
email String?
phone String?
name String?
createdAt DateTime @default(now())
bookingAudits BookingAudit[]
@@unique([userUuid])
@@unique([attendeeId])
@@unique([email]) // Prevent duplicate email actors
@@unique([phone]) // Prevent duplicate phone actors
@@index([email])
@@index([userUuid])
@@index([attendeeId])
@@index([pseudonymizedAt]) // For compliance cleanup jobs
}
```
**Key Design Decisions:**
- **UUID Primary Key**: Uses UUIDs for distributed system compatibility
- **Soft References**: Both `userUuid` and `attendeeId` are soft references without foreign key constraints, allowing the audit trail to persist independently even after users or attendees are deleted
- **User UUID Reference**: Uses `User.uuid` instead of `User.id` for stable identification across distributed systems
- **Unique Constraints**: Prevents duplicate audit actors for the same user/email/phone
- **Multiple Identity Fields**: Supports different actor types (users, guests, attendees, system)
- **Extensible System Actors**: Architecture supports multiple system actors (e.g., Cron, Webhooks, API integrations, Background Workers) for granular tracking of automated operations
- **Identity Fields for Audit Retention**: Identity fields (email, phone, name) are primarily used for GUEST/SYSTEM actors. For USER and ATTENDEE types, these fields can be anonymized when the source record is deleted to preserve the audit trail while removing PII
---
### 2. BookingAudit Table
The `BookingAudit` table stores audit records for all booking-related actions.
```prisma
model BookingAudit {
id String @id @default(uuid(7)) @db.Uuid
// bookingUid is stored as a plain string (not a foreign key relation) to preserve the audit trail
// even after the booking is deleted. This is intentional for audit log integrity:
// - Audit logs are immutable historical records that should persist independently
// - When a booking is deleted, we still need to know which booking the audit log belonged to
// - Using a plain string instead of a relation prevents bookingUid from becoming NULL on booking deletion
// - This allows users to view complete audit history for deleted bookings
bookingUid String
// Actor who performed the action (USER, GUEST, or SYSTEM)
// Stored in AuditActor table to maintain audit trail even after user deletion
actorId String
// Restrict onDelete to prevent deletion of audit actor if there are any booking audits associated with it
actor AuditActor @relation(fields: [actorId], references: [id], onDelete: Restrict)
type BookingAuditType
action BookingAuditAction
// Timestamp of the actual booking change (business event time)
// Important: May differ from createdAt if audit is processed asynchronously
timestamp DateTime
// Database record timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
data Json?
@@index([actorId])
@@index([bookingUid])
}
```
**Key Design Decisions:**
- **UUID v7 Primary Key**: Uses `uuid(7)` for time-sortable IDs, providing chronological ordering and improved database performance
- **No Foreign Key to Booking**: `bookingUid` is stored as a plain string without a foreign key constraint to preserve audit trail integrity even after bookings are deleted
- **Restrict on Delete**: `onDelete: Restrict` prevents actor deletion if audit records exist
- **Required Action**: Every audit record must specify a business action, ensuring explicit tracking of what happened
- **Explicit Timestamp**: The `timestamp` field has no default and must be explicitly provided, representing when the business event actually occurred
- **Separate Database Timestamps**: `createdAt` and `updatedAt` track when the audit record itself was created/modified, distinct from the business event time
- **JSON Data Field**: Flexible schema for storing action-specific contextual data
- **Indexed Fields**: Efficient queries by `bookingUid` and `actorId`
**Protecting the Audit Trail:**
- **AuditActor Record Deletion Prevention**: The database will reject any attempt to delete an `AuditActor` record that has associated `BookingAudit` records, ensuring audit records never become orphaned
- **User Deletion Unaffected**: When a `User` is deleted from the system, their corresponding `AuditActor` record persists (with `userUuid` set to null), maintaining the complete audit history
## Enums
### AuditActorType
Defines the type of entity performing an action:
```prisma
enum AuditActorType {
USER @map("user") // Registered Cal.com user (stored here for audit retention even after user deletion)
// Considering renaming it to ANONYMOUS to avoid confusion with Guest of a booking
GUEST @map("guest") // Non-registered user
ATTENDEE @map("attendee") // Guest who booked (has Attendee record)
SYSTEM @map("system") // Automated actions
}
```
**Values:**
- **USER**: Registered Cal.com users who perform actions on bookings
- **GUEST**: Non-registered users (typically booking guests)
- **ATTENDEE**: Guests who have an Attendee record associated with a booking
- **SYSTEM**: Automated system actions (scheduled jobs, webhooks, etc.)
---
### BookingAuditType
Defines the category of audit record:
```prisma
enum BookingAuditType {
RECORD_CREATED @map("record_created")
RECORD_UPDATED @map("record_updated")
RECORD_DELETED @map("record_deleted")
}
```
**Usage Pattern:**
- **RECORD_CREATED**: Initial booking creation
- **RECORD_UPDATED**: Any modification to existing booking (most common)
- **RECORD_DELETED**: Permanent deletion (rarely used, as bookings are typically cancelled)
---
### BookingAuditAction
Defines specific actions performed on bookings:
```prisma
enum BookingAuditAction {
// Booking lifecycle
CREATED @map("created")
// Status changes
CANCELLED @map("cancelled")
ACCEPTED @map("accepted")
REJECTED @map("rejected")
PENDING @map("pending")
AWAITING_HOST @map("awaiting_host")
RESCHEDULED @map("rescheduled")
// Attendee management
ATTENDEE_ADDED @map("attendee_added")
ATTENDEE_REMOVED @map("attendee_removed")
// Assignment/Reassignment
REASSIGNMENT @map("reassignment")
// Meeting details
LOCATION_CHANGED @map("location_changed")
// No-show tracking
HOST_NO_SHOW_UPDATED @map("host_no_show_updated")
ATTENDEE_NO_SHOW_UPDATED @map("attendee_no_show_updated")
// Rescheduling
RESCHEDULE_REQUESTED @map("reschedule_requested")
}
```
**Action Categories:**
1. **Booking Lifecycle**: Track creation events
- `CREATED`
2. **Status Changes**: Track booking lifecycle transitions
- `CANCELLED`, `ACCEPTED`, `REJECTED`, `PENDING`, `AWAITING_HOST`, `RESCHEDULED`
3. **Attendee Management**: Track changes to booking participants
- `ATTENDEE_ADDED`, `ATTENDEE_REMOVED`
4. **Assignment/Reassignment**: Track booking host assignment changes
- `REASSIGNMENT`
5. **Meeting Details**: Track changes to meeting logistics
- `LOCATION_CHANGED`
6. **No-Show Tracking**: Track attendance issues
- `HOST_NO_SHOW_UPDATED`, `ATTENDEE_NO_SHOW_UPDATED`
7. **Rescheduling**: Track reschedule requests
- `RESCHEDULE_REQUESTED`
---
## Schema Structure - Change Tracking
All audit actions follow a consistent structure for tracking changes. This structure ensures complete audit trail coverage by capturing both the old and new values for every tracked field.
### Core Pattern
Each action stores a flat object with all relevant fields. Each field tracks both old and new values:
```typescript
{
field1: { old: T | null, new: T },
field2: { old: T | null, new: T },
field3: { old: T | null, new: T } // Optional fields as needed
}
```
### Change Tracking
**All fields use the same pattern** - `{ old: T | null, new: T }`:
- `old`: The previous value (null if the field didn't exist before)
- `new`: The new value after the change
**Examples:**
```typescript
// Simple field changes
status: { old: "ACCEPTED", new: "CANCELLED" }
location: { old: "Zoom", new: "Google Meet" }
// New fields (old is null)
cancellationReason: { old: null, new: "Client requested" }
```
### Semantic Clarity at Application Layer
**Action Services decide what to display prominently.** Each Action Service has methods like `getDisplayDetails()` that determine:
- Which fields are most important to show by default
- Which fields should be available but not emphasized
- How to format the data for display
This keeps the data structure simple while maintaining semantic clarity where it matters - in the UI.
### Benefits
1. **Complete Audit Trail**: Full before/after state captured for every change
2. **Self-Contained Records**: Each record has complete context without querying previous records
3. **Simple Structure**: Flat object, easy to work with and extend
4. **Better UI**: Action Services decide what to emphasize based on user needs
5. **State Reconstruction**: Can rebuild booking state at any point in the audit timeline
6. **Easier Debugging**: See exact state transitions in each record
7. **Type Safety**: Zod schemas validate the structure while keeping it flexible
### Examples by Action
#### Simple Action
```typescript
// LOCATION_CHANGED
{
location: { old: "Zoom", new: "Google Meet" }
}
```
#### Action with Multiple Fields
```typescript
// CANCELLED
{
cancellationReason: { old: null, new: "Client requested" },
cancelledBy: { old: null, new: "user@example.com" },
status: { old: "ACCEPTED", new: "CANCELLED" }
}
```
---
## JSON Data Schemas by Action
The `BookingAudit.data` field stores action-specific context. Each action has its own schema defined in a dedicated Action Helper Service using Zod validation.
### Booking Lifecycle Actions
#### CREATED
Used when a booking is initially created. Records the complete state at creation time.
```typescript
{
startTime: string // ISO 8601 timestamp
endTime: string // ISO 8601 timestamp
status: BookingStatus // Actual booking status (any value from BookingStatus enum)
}
```
**Design Decision:** The `status` field accepts any `BookingStatus` value, not just the expected creation statuses (ACCEPTED, PENDING, AWAITING_HOST). This follows the principle of capturing reality rather than enforcing business rules in the audit layer. If a booking is ever created with an unexpected status due to a bug, we want to record that fact for debugging purposes rather than silently skip the audit record.
**Note:** The CREATED action is unique - it captures the initial booking state at creation, so it doesn't use the `{ old, new }` tracking pattern. It's a flat object with just the initial values: `{ startTime, endTime, status }`.
---
### Status Change Actions
#### ACCEPTED
Used when a booking status changes to accepted.
```typescript
{
status // { old: "PENDING", new: "ACCEPTED" }
}
```
#### CANCELLED
```typescript
{
cancellationReason, // { old: null, new: "Client requested" }
cancelledBy, // { old: null, new: "user@example.com" }
status // { old: "ACCEPTED", new: "CANCELLED" }
}
```
#### REJECTED
```typescript
{
rejectionReason, // { old: null, new: "Does not meet requirements" }
status // { old: "PENDING", new: "REJECTED" }
}
```
#### RESCHEDULED
```typescript
{
startTime, // { old: "2024-01-15T10:00:00Z", new: "2024-01-16T14:00:00Z" }
endTime // { old: "2024-01-15T11:00:00Z", new: "2024-01-16T15:00:00Z" }
}
```
#### RESCHEDULE_REQUESTED
```typescript
{
cancellationReason, // { old: null, new: "Need to reschedule" }
cancelledBy, // { old: null, new: "user@example.com" }
rescheduled? // { old: false, new: true } - optional
}
```
---
### Attendee Management Actions
#### ATTENDEE_ADDED
```typescript
{
attendees // { old: ["email1@example.com", ...], new: ["email1@example.com", "email2@example.com", ...] }
}
```
Tracks attendee(s) that were added in this action. The field stores the state change: `old` contains attendees before addition, `new` contains attendees after addition. The actual added attendees are computed as the difference (new - old).
#### ATTENDEE_REMOVED
```typescript
{
attendees // { old: ["email1@example.com", ...], new: ["email2@example.com", ...] }
}
```
Tracks attendee(s) that were removed in this action. The field stores the state change: `old` contains attendees before removal, `new` contains remaining attendees after removal. The actual removed attendees are computed as the difference (old - new).
---
### Assignment/Reassignment Actions
#### REASSIGNMENT
```typescript
{
assignedToId, // { old: 123, new: 456 }
assignedById, // { old: 789, new: 789 }
reassignmentReason, // { old: null, new: "Coverage needed" }
userPrimaryEmail?, // { old: "old@cal.com", new: "new@cal.com" } - optional
title? // { old: "Meeting with A", new: "Meeting with B" } - optional
}
```
---
### Meeting Details Actions
#### LOCATION_CHANGED
```typescript
{
location // { old: "Zoom", new: "Google Meet" }
}
```
---
### No-Show Tracking Actions
#### HOST_NO_SHOW_UPDATED
```typescript
{
noShowHost // { old: false, new: true }
}
```
#### ATTENDEE_NO_SHOW_UPDATED
```typescript
{
noShowAttendee // { old: false, new: true }
}
```
---
**Important Notes:**
- Each schema is strictly typed and validated using the corresponding Action Helper Service
- Actor information is **NOT** stored in the JSON data - it's captured through the `actorId` relation linking to the `Actor` table
- All action schemas are defined in `/packages/features/booking-audit/lib/actions/`
---
### Supporting Schemas
#### Change Tracking Pattern
**All changes use the `{ old, new }` pattern:**
Each field tracks both old and new values:
```typescript
fieldName: {
old: T | null, // Previous value (null if field didn't exist)
new: T // New value
}
```
**Benefits:**
- Complete before/after state in every record
- Self-contained audit entries (no need to query previous records)
- Clear state transitions
- Easier debugging and UI display
- Simple flat structure that's easy to work with
---
## Table Relationships
```
AuditActor (1) ──────< (many) BookingAudit
↑
│ (soft reference, no FK)
├──────────── User (via userUuid)
│ (soft reference, no FK)
└──────────── Attendee (via attendeeId)
```
**Relationship Details:**
1. **AuditActor → BookingAudit**: One-to-Many (with FK constraint)
- Each AuditActor can have multiple audit records
- Each BookingAudit references exactly one AuditActor
- `onDelete: Restrict` prevents AuditActor deletion if audits exist
2. **AuditActor → User**: Soft Reference (no FK constraint)
- `userUuid` references `User.uuid` but without foreign key constraint
- Nullable to preserve audits after user deletion
- Unique constraint ensures one AuditActor per User UUID
- Audit trail persists independently of user lifecycle
3. **AuditActor → Attendee**: Soft Reference (no FK constraint)
- `attendeeId` references `Attendee.id` but without foreign key constraint
- Links guest actors to their booking attendee records
- Nullable to preserve audits after attendee deletion
- Unique constraint ensures one AuditActor per Attendee
- Audit trail persists independently of attendee lifecycle
---
## Indexing Strategy
### AuditActor Table Indexes
```prisma
@@index([email]) // Fast lookups by guest email
@@index([userUuid]) // Fast lookups by user UUID
@@index([attendeeId]) // Fast lookups by attendee ID
```
**Query Patterns:**
- Find all actions by a user (via UUID)
- Find all actions by a guest email
- Find all actions by an attendee
---
### BookingAudit Table Indexes
```prisma
@@index([bookingId]) // Primary query pattern
@@index([actorId]) // Secondary query pattern
```
**Query Patterns:**
- **bookingId**: "Show me all audits for this booking" (most common)
- **actorId**: "Show me all actions by this actor"
---
## Special Actors
### SYSTEM Actor
The system actor represents automated actions and has a fixed UUID:
```typescript
const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
```
**Used For:**
- Automated status changes
- System-generated meeting URLs
- Scheduled operations (cron jobs)
- Webhook-triggered actions
- Any action not directly initiated by a user or guest
**Properties:**
- Type: `ActorType.SYSTEM`
- No userUuid, attendeeId, email, or phone
- Single instance across the entire system
---
## Schema Version Management
The audit system uses **per-action versioning** rather than global versioning. Each `BookingAuditAction` maintains its own schema version independently.
### Why Per-Action Versioning?
Different actions have different data requirements:
- `CANCELLED` needs `cancellationReason`
- `RESCHEDULED` needs new `startTime` and `endTime`
- `ATTENDEE_ADDED` needs `attendee` information
- `REASSIGNMENT` needs assignment context
When we update the schema for one action, we don't want to affect other actions.
### Implementation Approach
Each action has a dedicated Action Service that manages its own versioning independently. The service defines:
1. **Schema Definition**: Zod schemas for data validation
2. **Schema Version**: Each action maintains its own VERSION constant
3. **Nested Structure**: Version stored separately from audit data: `{ version, data: {} }`
4. **Type Separation**: Distinct types for input (no version) and stored format (with version)
5. **Validation**: Type-safe validation of audit data
6. **Display Logic**: How to render the audit record in the UI
**Example Action Services:**
- `CreatedAuditActionService` → Handles `CREATED` action
- `CancelledAuditActionService` → Handles `CANCELLED` action
- `RescheduledAuditActionService` → Handles `RESCHEDULED` action
- `ReassignmentAuditActionService` → Handles `REASSIGNMENT` action
### Version Storage Structure
Audit data is stored with a nested structure that separates version metadata from actual audit data:
```typescript
{
version: 1,
data: {
cancellationReason: { old: null, new: "Client requested" },
cancelledBy: { old: null, new: "user@cal.com" },
status: { old: "ACCEPTED", new: "CANCELLED" }
}
}
```
**Benefits of Nested Structure:**
- Clear separation between metadata (version) and actual audit data
- Makes it easy to extract just the data fields for display
- Version handling is transparent to end users
- Schema evolution is self-documenting
**Key Points:**
- Callers pass unversioned data (just the fields)
- `parse()` automatically wraps input with version before storing
- `parseStored()` validates stored data including version
- Display methods receive full stored record but only show data fields
- Type system enforces correct usage (input vs stored types)
### Benefits of Per-Action Versioning
- **Independent Evolution**: Update one action's schema without affecting others
- **Explicit Changes**: Version increments are tied to specific business operations
- **No Migration Required**: Old records handled via discriminated unions
- **Clear History**: Can track schema changes per action type over time
- **Type Safety**: Each action has strongly-typed schemas for input and storage
- **Caller Simplicity**: Callers don't need to know about versioning
- **Display Isolation**: Version handling is internal to Action Services
When adding a new version (e.g., v2 with a new field):
**Migration Steps:**
1. Create `dataSchemaV2` with new fields
2. Create `schemaV2` with `version: z.literal(2)`
3. Update `schema` to discriminated union supporting both v1 and v2
4. Update `VERSION` constant to 2
5. Update `parse()` to use v2 schema
6. Update display methods to handle both versions
7. No changes needed to callers or database
---
## Design Principles
### 1. Immutability
Audit records are append-only. Once created, they are never modified or deleted. This ensures:
- Complete historical accuracy
- Tamper-proof audit trail
- Compliance with audit requirements
### 2. Historical Preservation
Actor information is preserved even after source records are deleted:
- User deletion doesn't remove AuditActor records - they persist with anonymized identity fields
- AuditActor table maintains historical records by nullifying PII fields (email, phone, name) when source records are deleted
- Audit trail remains complete and queryable even after user/attendee deletion
### 3. Flexibility
The JSON `data` field provides schema flexibility:
- Action-specific context without database schema changes
- Easy addition of new fields for future actions
- Backward compatible with versioning
### 4. Traceability
Every action is fully traceable:
- Who: Actor with type and identity
- What: Type and Action enums
- When: Explicit timestamp
- Why/How: Contextual data in JSON field
### 5. Integrity
Database constraints ensure data quality:
- Foreign key constraints prevent orphaned records
- `onDelete: Restrict` protects audit integrity
- Unique constraints prevent duplicate actors
- Indexes ensure query performance
### 6. Reality Over Enforcement
**The audit system records actual state, not expected state.**
The audit system is designed to capture what actually happened, not to enforce business rules:
- **Store Actual Values**: When recording state (e.g., booking status at creation), store the actual value from the database without validation or filtering
- **No Type Guards for State**: Avoid conditionally creating audit records based on whether the state matches expectations
- **Bug Detection**: If a booking is created with an unexpected status, record it—this becomes valuable debugging information
- **Single Source of Truth**: Let the business logic layer enforce rules; the audit layer's job is faithful recording
**Example:**
```typescript
// ❌ BAD: Enforcing expected values
if (booking.status === 'ACCEPTED' || booking.status === 'PENDING' || booking.status === 'AWAITING_HOST') {
// Only audit if status is "expected"
await auditTaskConsumer.onBookingAction(...);
}
// ✅ GOOD: Recording actual state
await bookingAuditProducerService.queueAudit({
bookingUid,
actor,
action: "CREATED",
data: {
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
status: booking.status // Whatever it actually is
}
});
```
**Benefits:**
- Audit trail shows real system behavior, including anomalies
- Easier to debug issues (incomplete audits hide problems)
- Schemas naturally evolve with business requirements
- No silent failures where audits are skipped
**When to Validate:**
- Validate data structure (required fields, types) ✅
- Do NOT validate business logic (expected values, state transitions) ❌
---
## 7. Compliance & Data Privacy
**GDPR & HIPAA Compliance:**
- **AuditActor records are NOT deleted on user deletion** - Instead, AuditActor records persist with PII fields nullified to preserve the immutable audit trail as required by HIPAA §164.312(b)
- **Cal.com's HIPAA compliance** requires audit records to remain immutable and tamper-proof. The AuditActor table design ensures BookingAudit records are never modified, only the referenced AuditActor record has its PII fields anonymized
- **GDPR Article 17 compliance** is achieved through anonymization: When a user/guest requests deletion, set `userUuid=null`, `email=null`, `phone=null`, `name=null` on the AuditActor record
- **Retention Policy**: Application-level logic should handle retention policies and full anonymization after required retention periods
**Implementation Pattern:**
```typescript
// On user deletion: Anonymize PII, don't delete the actor record
await prisma.auditActor.update({
where: { userUuid: deletedUserUuid },
data: {
userUuid: null,
email: null,
phone: null,
name: null,
pseudonymizedAt: new Date(),
scheduledDeletionDate: new Date(Date.now() + 7 * 365 * 24 * 60 * 60 * 1000)
}
});
// Result: All BookingAudit records reference pseudonymized actor - audit trail preserved, immutable
```
---
## Service Layer
### BookingEventHandlerService - Entry Point
`BookingEventHandlerService` is the primary entry point for tracking any booking changes. It acts as a coordinator that:
1. **Receives booking events** from various parts of the application (booking creation, status changes, updates, etc.)
2. **Queues audit tasks** via BookingAuditProducerService for asynchronous processing
3. **Handles other side effects** such as webhooks, notifications, and workflow triggers
### BookingAuditTaskConsumer
The audit system processes audit records through `BookingAuditTaskConsumer`, which provides:
### Core Method
- `onBookingAction(params)` - Generic method that handles any booking action
- Accepts a single object parameter with `bookingUid`, `actor`, `action`, and `data`
- Derives the record type (RECORD_CREATED, RECORD_UPDATED, RECORD_DELETED) from the action
- Routes to appropriate action service for data validation and formatting
### Record Type Mapping
- `getRecordType({ action })` - Maps booking actions to audit record types:
- `CREATED` → `RECORD_CREATED`
- Future actions like `CANCELLED`, `RESCHEDULED` → `RECORD_UPDATED`
- Future actions like `DELETED` → `RECORD_DELETED`
### Actor Management
- `resolveActorId()` - Resolves Actor objects to actor IDs in the AuditActor table
- Automatic AuditActor creation/lookup for registered users, guests, and attendees
- System actor for automated actions
### Future: Trigger.dev Task Orchestration
**Current Flow (Asynchronous with Task Queue):**
```
Booking Endpoint
↓
BookingEventHandler.onBookingCreated() [orchestrator]
├─ bookingAuditProducerService.queueAudit({ bookingUid, actor, action, data })
├─ hashedLinkService.validateAndIncrementUsage(hashedLink)
└─ Other side effects...
Task Queue
└─ Task: Booking Audit → BookingAuditTaskConsumer.onBookingAction({ bookingUid, actor, action, data })
```
**Future Flow (Async with Trigger.dev):**
```
Booking Endpoint
↓
BookingEventHandler.onBookingCreated() [orchestrator]
├─ tasks.trigger('bookingAudit', { bookingUid, actor, action, 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 → BookingAuditTaskConsumer.onBookingAction({ bookingUid, actor, action, data }) (with retries, monitoring)
├─ Task: Hashed Link Invalidation (independent)
├─ Task: Email & SMS Notifications (independent)
└─ Task: Workflow Triggers (independent)
```
**Key Principles:**
- **BookingEventHandler remains the single orchestrator** - Entry point for all side effects
- **Each task is independent** - One task failure doesn't block others
- **Persistent queue** - Trigger.dev handles retries, monitoring, and observability
- **Easy to add features** - New side effect = new task definition, no BookingEventHandler complexity
- **Immutable audit records** - Booking audit is just one of many tasks, preserving the immutability principle
---
## Summary
The Booking Audit System provides a robust, scalable architecture for tracking all booking-related actions. Key features include:
- ✅ **Complete Audit Trail**: Every action tracked with full context
- ✅ **Historical Preservation**: Data retained even after deletions through PII anonymization
- ✅ **Flexible Schema**: JSON data supports evolution without migrations
- ✅ **Strong Integrity**: Database constraints ensure data quality
- ✅ **Performance**: Strategic indexes for common query patterns, UUID v7 for time-sortable IDs
- ✅ **HIPAA & GDPR Compliant**: Immutable audit records, anonymized actors, compliance-ready
- ✅ **Reality-Based Recording**: Captures actual state, aiding in debugging and analysis
- ✅ **Independent Audit Trail**: No foreign key to bookings ensures audit history persists after booking deletion
This architecture supports compliance requirements (HIPAA §164.312(b), GDPR Article 17), debugging, analytics, and provides transparency for both users and administrators.
+239
View File
@@ -0,0 +1,239 @@
# Booking Audit System
## Overview
The Booking Audit System tracks all actions and changes related to bookings in Cal.com. The architecture is built around two core tables (`AuditActor` and `BookingAudit`) that work together to maintain a complete, immutable audit trail.
## Database Architecture
### Core Tables
1. **AuditActor**: Stores information about entities that perform actions on bookings. Maintains historical records even after users are deleted.
2. **BookingAudit**: Stores audit records for all booking-related actions.
### Key Design Decisions
**AuditActor Table:**
- Uses UUID primary keys for distributed system compatibility
- Soft references to `User` (via `userUuid`) and `Attendee` (via `attendeeId`) without foreign key constraints
- Audit trail persists independently even after users/attendees are deleted
- Unique constraints prevent duplicate audit actors
- Supports actor types: USER, GUEST, ATTENDEE, SYSTEM, APP
- Identity fields (email, phone, name) can be anonymized when source records are deleted
**BookingAudit Table:**
- Uses UUID v7 primary keys for time-sortable IDs
- `bookingUid` stored as plain string (no foreign key) to preserve audit trail after booking deletion
- `onDelete: Restrict` prevents actor deletion if audit records exist
- Explicit `timestamp` field represents business event time (may differ from `createdAt` if processed asynchronously)
- `operationId` required field for correlating audit logs from a single user action across different audit types (BookingAudit, UserAudit, etc.)
- JSON `data` field stores action-specific contextual data
- Indexed for efficient queries by `bookingUid`, `actorId`, `timestamp`, and `operationId`
**Protecting the Audit Trail:**
- Database rejects deletion of `AuditActor` records with associated `BookingAudit` records
- When a `User` is deleted, their `AuditActor` record persists with `userUuid` set to null
## Actor Types
- **USER**: Registered Cal.com users
- **GUEST**: Non-registered users (typically booking guests)
- **ATTENDEE**: Guests who have an Attendee record associated with a booking
- **SYSTEM**: Automated system actions
## Source and Actor Design Pattern
The booking audit system uses two complementary fields:
### Source: The Channel
**`source`** identifies how the action was initiated:
- **WEBAPP**: Cal.com web application
- **API_V1**: API v1 endpoint
- **API_V2**: API v2 endpoint
- **WEBHOOK**: External webhook (e.g., Stripe)
- **UNKNOWN**: Source cannot be determined
### Actor: The Entity
**`actor`** identifies who or what performed the action:
- **User Actor**: Registered Cal.com user
- **Guest Actor**: Non-registered guest
- **Attendee Actor**: Attendee associated with a booking
- **System Actor**: Automated system action (generic or named for specific webhooks/services)
This separation enables clear compliance trails, easier debugging, better analytics, and security by distinguishing user-initiated vs automated actions.
## Audit Actions
The system tracks various booking actions including:
- **CREATED**: Initial booking creation
- **RESCHEDULED**: Booking time/date changed
- **ACCEPTED**: Booking request approved
- **CANCELLED**: Booking cancelled
- **REJECTED**: Booking request declined
- **RESCHEDULE_REQUESTED**: Request to reschedule
- **ATTENDEE_ADDED**: New attendee added
- **ATTENDEE_REMOVED**: Attendee removed
- **REASSIGNMENT**: Booking reassigned to different host
- **LOCATION_CHANGED**: Meeting location updated
- **HOST_NO_SHOW_UPDATED**: Host no-show status changed
- **ATTENDEE_NO_SHOW_UPDATED**: Attendee no-show status changed
- **SEAT_BOOKED**: Seat reserved in group booking
- **SEAT_RESCHEDULED**: Seat rescheduled in group booking
## Data Structure Pattern
Most audit actions track changes using a consistent structure:
- Each field tracks both old and new values: `{ old: T | null, new: T }`
- `old`: Previous value (null if field didn't exist before)
- `new`: New value after the change
- Complete before/after state captured in every record
**Exception:** The CREATED action captures initial booking state at creation using a flat object with initial values.
## Schema Versioning
The audit system uses per-action versioning. Each action maintains its own schema version independently.
**Benefits:**
- Update one action's schema without affecting others
- Old records handled via discriminated unions (no migration required)
- Strongly-typed schemas for input and storage
**Storage Structure:**
Version stored separately from audit data: `{ version, data: {} }`
## Table Relationships
```
AuditActor (1) ──────< (many) BookingAudit
↑
│ (soft reference, no FK)
├──────────── User (via userUuid)
│ (soft reference, no FK)
└──────────── Attendee (via attendeeId)
```
**Relationship Details:**
1. **AuditActor → BookingAudit**: One-to-Many with FK constraint and `onDelete: Restrict`
2. **AuditActor → User**: Soft reference (no FK) - nullable to preserve audits after user deletion
3. **AuditActor → Attendee**: Soft reference (no FK) - nullable to preserve audits after attendee deletion
## Indexing Strategy
**AuditActor Table:**
- Indexed on `email`, `userUuid`, `attendeeId` for fast lookups
- Indexed on `pseudonymizedAt` for compliance cleanup jobs
**BookingAudit Table:**
- Indexed on `bookingUid` (primary query pattern)
- Indexed on `actorId` (secondary query pattern)
- Indexed on `timestamp` (time-based sorting and filtering)
- Indexed on `operationId` (correlating multi-booking operations)
## Special Actors
**SYSTEM Actor:**
- Fixed UUID representing automated actions
- Used for automated status changes, system-generated meeting URLs, scheduled operations
- Single instance across the entire system
- No userUuid, attendeeId, email, or phone
## Design Principles
### 1. Immutability
Audit records are append-only. Once created, they are never modified or deleted. This ensures complete historical accuracy and a tamper-proof audit trail.
### 2. Historical Preservation
Actor information is preserved even after source records are deleted. AuditActor records persist with anonymized identity fields. The audit trail remains complete and queryable even after user/attendee deletion.
### 3. Flexibility
The JSON `data` field provides schema flexibility for action-specific context without database schema changes. Backward compatible with versioning.
### 4. Traceability
Every action is fully traceable with who (actor), what (action), when (timestamp), and contextual data.
### 5. Integrity
Database constraints ensure data quality through foreign keys, `onDelete: Restrict` protection, unique constraints, and strategic indexes.
### 6. Reality Over Enforcement
The audit system records actual state, not expected state. It captures what actually happened without enforcing business rules:
- Store actual values from the database
- Record all actions, including anomalies
- Business logic layer enforces rules; audit layer records faithfully
**Benefits:**
- Shows real system behavior including anomalies
- Easier debugging
- No silent failures
### 7. Compliance & Data Privacy
**GDPR & HIPAA Compliance:**
- AuditActor records persist with PII fields nullified when users are deleted
- Maintains immutable audit trail as required by HIPAA §164.312(b)
- GDPR Article 17 compliance through anonymization: `userUuid`, `email`, `phone`, `name` set to null
- Application-level logic handles retention policies
### 8. Queue Privacy
**Zero PII in Queue:**
The audit system works with third-party queue providers without exposing PII:
- **UserActor**: Only `userUuid` is queued
- **AttendeeActor**: Only `attendeeId` is queued
- **GuestActor**: Actor record created before queueing; only `actorId` is queued
**Benefits:**
- Safe to use third-party queue providers
- GDPR and HIPAA compliant
- Audit trail remains complete even if queue is compromised
## Service Architecture
**BookingEventHandlerService** is the primary entry point for tracking booking changes. It:
1. Receives booking events from various parts of the application
2. Queues audit tasks via BookingAuditProducerService
3. Handles other side effects such as webhooks and notifications
**Queue Payload Structure:**
- `bookingUid`: String identifier for the booking
- `actor`: ID-only actor object (`userUuid`, `attendeeId`, or `actorId`)
- `organizationId`: Number (for feature flag checks)
- `action`: Enum value (e.g., "CREATED", "CANCELLED")
- `operationId`: Required string for correlating related audit logs
- `data`: Action-specific data
- `timestamp`: Number (milliseconds since epoch)
- `source`: Action source (API_V1, API_V2, WEBAPP, WEBHOOK, UNKNOWN)
**BookingAuditTaskConsumer** processes audit records:
- Validates queue payload structure
- Resolves actor IDs to AuditActor records
- Routes to appropriate action service for data validation and formatting
- Creates immutable audit records in BookingAudit table
## Operation ID
The `operationId` field correlates audit logs that result from a single user action affecting multiple bookings or across different audit types (BookingAudit, UserAudit, etc.).
**Benefits:**
- Easy to identify all audits from a single user action
- Track bulk vs individual operations
- Improved debugging and understanding of action scope
- Indexed field for fast lookups
## Summary
The Booking Audit System provides a robust, scalable architecture for tracking all booking-related actions:
- **Complete Audit Trail**: Every action tracked with full context
- **Historical Preservation**: Data retained even after deletions through PII anonymization
- **Flexible Schema**: JSON data supports evolution without migrations
- **Strong Integrity**: Database constraints ensure data quality
- **Performance**: Strategic indexes, UUID v7 for time-sortable IDs
- **HIPAA & GDPR Compliant**: Immutable audit records, anonymized actors
- **Reality-Based Recording**: Captures actual state for debugging
- **Independent Audit Trail**: Persists after booking deletion
- **Operation Correlation**: Links related audit logs across different audit types
This architecture supports compliance requirements, debugging, analytics, and provides transparency for users and administrators.
@@ -0,0 +1,14 @@
import { createContainer } from "@calcom/features/di/di";
import type { IAuditActorRepository } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
import {
moduleLoader as auditActorRepositoryModule,
} from "./AuditActorRepository.module";
const container = createContainer();
export function getAuditActorRepository(): IAuditActorRepository {
auditActorRepositoryModule.loadModule(container);
return container.get<IAuditActorRepository>(auditActorRepositoryModule.token);
}
@@ -2,6 +2,7 @@ import { BookingAuditTaskerProducerService } from "@calcom/features/booking-audi
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { moduleLoader as taskerModuleLoader } from "@calcom/features/di/shared/services/tasker.service";
import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service";
import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
@@ -17,6 +18,7 @@ const loadModule = bindModuleToClassOnToken({
depsMap: {
tasker: taskerModuleLoader,
log: loggerModuleLoader,
auditActorRepository: auditActorRepositoryModuleLoader,
},
});
@@ -3,7 +3,10 @@ import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/token
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
import { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking";
import { moduleLoader as attendeeRepositoryModuleLoader } from "@calcom/features/bookings/di/Attendee.module";
import { moduleLoader as membershipRepositoryModuleLoader } from "@calcom/features/users/di/MembershipRepository.module";
import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service";
import { moduleLoader as credentialRepositoryModuleLoader } from "@calcom/features/credentials/di/CredentialRepository.module";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
@@ -22,7 +25,10 @@ const loadModule = bindModuleToClassOnToken({
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
userRepository: userRepositoryModuleLoader,
bookingRepository: bookingRepositoryModuleLoader,
membershipRepository: membershipRepositoryModuleLoader,
attendeeRepository: attendeeRepositoryModuleLoader,
log: loggerModuleLoader,
credentialRepository: credentialRepositoryModuleLoader,
},
});
@@ -3,7 +3,7 @@ import { z } from "zod";
import { BookingStatusChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Accepted Audit Action Service
@@ -15,8 +15,7 @@ const fieldsSchemaV1 = z.object({
status: BookingStatusChangeSchema,
});
export class AcceptedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class AcceptedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "ACCEPTED" as const;
private static dataSchemaV1 = z.object({
@@ -60,17 +59,14 @@ export class AcceptedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.accepted" };
}
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): AcceptedAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): AcceptedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
@@ -2,7 +2,7 @@ import { z } from "zod";
import { StringArrayChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Attendee Added Audit Action Service
@@ -14,8 +14,7 @@ const fieldsSchemaV1 = z.object({
attendees: StringArrayChangeSchema,
});
export class AttendeeAddedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class AttendeeAddedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "ATTENDEE_ADDED" as const;
private static dataSchemaV1 = z.object({
@@ -59,17 +58,14 @@ export class AttendeeAddedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_added" };
}
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): AttendeeAddedAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): AttendeeAddedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const previousAttendeesSet = new Set(fields.attendees.old ?? []);
const addedAttendees = fields.attendees.new.filter(
(email) => !previousAttendeesSet.has(email)
@@ -2,7 +2,7 @@ import { z } from "zod";
import { BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Attendee No-Show Updated Audit Action Service
@@ -14,8 +14,7 @@ const fieldsSchemaV1 = z.object({
noShowAttendee: BooleanChangeSchema,
});
export class AttendeeNoShowUpdatedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class AttendeeNoShowUpdatedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const;
private static dataSchemaV1 = z.object({
@@ -59,17 +58,14 @@ export class AttendeeNoShowUpdatedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_no_show_updated" };
}
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): AttendeeNoShowUpdatedAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): AttendeeNoShowUpdatedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
noShowAttendee: fields.noShowAttendee.new,
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
@@ -2,7 +2,7 @@ import { z } from "zod";
import { StringArrayChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Attendee Removed Audit Action Service
@@ -14,8 +14,7 @@ const fieldsSchemaV1 = z.object({
attendees: StringArrayChangeSchema,
});
export class AttendeeRemovedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class AttendeeRemovedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "ATTENDEE_REMOVED" as const;
private static dataSchemaV1 = z.object({
@@ -59,17 +58,14 @@ export class AttendeeRemovedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_removed" };
}
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): AttendeeRemovedAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): AttendeeRemovedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const remainingAttendeesSet = new Set(fields.attendees.new ?? []);
const removedAttendees = (fields.attendees.old ?? []).filter(
(email) => !remainingAttendeesSet.has(email)
@@ -2,7 +2,7 @@ import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Cancelled Audit Action Service
@@ -16,8 +16,7 @@ const fieldsSchemaV1 = z.object({
status: StringChangeSchema,
});
export class CancelledAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class CancelledAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "CANCELLED" as const;
private static dataSchemaV1 = z.object({
@@ -61,17 +60,14 @@ export class CancelledAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.cancelled" };
}
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): CancelledAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): CancelledAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
cancellationReason: fields.cancellationReason.new ?? null,
previousReason: fields.cancellationReason.old ?? null,
@@ -2,7 +2,7 @@ import { z } from "zod";
import { BookingStatus } from "@calcom/prisma/enums";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Created Audit Action Service
@@ -17,10 +17,7 @@ const fieldsSchemaV1 = z.object({
status: z.nativeEnum(BookingStatus),
});
export class CreatedAuditActionService implements IAuditActionService<
typeof fieldsSchemaV1,
typeof fieldsSchemaV1
> {
export class CreatedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "CREATED" as const;
private static dataSchemaV1 = z.object({
@@ -61,18 +58,15 @@ export class CreatedAuditActionService implements IAuditActionService<
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.created" };
}
getDisplayJson({
storedData,
userTimeZone,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): CreatedAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): CreatedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const timeZone = userTimeZone;
return {
@@ -2,7 +2,7 @@ import { z } from "zod";
import { BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Host No-Show Updated Audit Action Service
@@ -14,8 +14,7 @@ const fieldsSchemaV1 = z.object({
noShowHost: BooleanChangeSchema,
});
export class HostNoShowUpdatedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class HostNoShowUpdatedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const;
private static dataSchemaV1 = z.object({
@@ -59,17 +58,14 @@ export class HostNoShowUpdatedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.host_no_show_updated" };
}
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): HostNoShowUpdatedAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): HostNoShowUpdatedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
noShowHost: fields.noShowHost.new,
previousNoShowHost: fields.noShowHost.old ?? null,
@@ -1,5 +1,3 @@
import { z } from "zod";
/**
* Represents a component that can be interpolated into translations
* Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.)
@@ -20,20 +18,35 @@ export type TranslationWithParams = {
components?: TranslationComponent[];
};
/**
* This is agnostic of the action and is common for all actions
*/
export type BaseStoredAuditData = {
version: number;
fields: Record<string, unknown>;
};
export type GetDisplayJsonParams = {
storedData: BaseStoredAuditData;
userTimeZone: string;
};
export type GetDisplayTitleParams = {
storedData: BaseStoredAuditData;
userTimeZone: string;
};
/**
* 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.
* while maintaining runtime type safety through Zod schema validation.
*
* @template TLatestFieldsSchema - The Zod schema type for the latest version's audit fields (write operations)
* @template TStoredFieldsSchema - The Zod schema type for all supported versions' audit fields (read operations, union type)
* All methods use common base types at the interface level, while implementations
* validate to their specific schemas internally.
*/
export interface IAuditActionService<
TLatestFieldsSchema extends z.ZodTypeAny,
TStoredFieldsSchema extends z.ZodTypeAny
> {
export interface IAuditActionService {
/**
* Current version number for this action type
*/
@@ -44,7 +57,7 @@ export interface IAuditActionService<
* @param fields - Raw input fields (just the audit fields)
* @returns Parsed data with version wrapper { version, fields }
*/
getVersionedData(fields: unknown): { version: number; fields: z.infer<TLatestFieldsSchema> };
getVersionedData(fields: unknown): BaseStoredAuditData;
/**
* Parse stored audit record (includes version wrapper)
@@ -52,7 +65,7 @@ export interface IAuditActionService<
* @param data - Stored data from database (can be any version)
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
*/
parseStored(data: unknown): { version: number; fields: z.infer<TStoredFieldsSchema> };
parseStored(data: unknown): BaseStoredAuditData;
/**
* Extract version number from stored data
@@ -69,7 +82,7 @@ export interface IAuditActionService<
* @param params.userTimeZone - User's timezone for datetime formatting (required)
* @returns The fields object without version wrapper and we decide what fields to show to the client
*/
getDisplayJson?(params: { storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }; userTimeZone: string }): unknown;
getDisplayJson?(params: GetDisplayJsonParams): Record<string, unknown>;
/**
* Get the display title for the audit action
@@ -80,7 +93,7 @@ export interface IAuditActionService<
* @param params.userTimeZone - User's timezone for date formatting (required)
* @returns Translation key with optional interpolation params
*/
getDisplayTitle(params: { storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }; userTimeZone: string }): Promise<TranslationWithParams>;
getDisplayTitle(params: GetDisplayTitleParams): Promise<TranslationWithParams>;
/**
* Returns additional display fields with translation keys for frontend rendering
@@ -88,7 +101,7 @@ export interface IAuditActionService<
* @param storedData - Parsed stored data { version, fields }
* @returns Array of field objects with label and value translation keys
*/
getDisplayFields?(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): Array<{
getDisplayFields?(storedData: BaseStoredAuditData): Array<{
labelKey: string; // Translation key for field label
valueKey: string; // Translation key for field value
}>;
@@ -111,6 +124,6 @@ export interface IAuditActionService<
/**
* Always set, either migrated or original
*/
latestData: z.infer<TLatestFieldsSchema>;
latestData: Record<string, unknown>;
};
}
@@ -3,7 +3,7 @@ import { z } from "zod";
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams } from "./IAuditActionService";
import { getTranslation } from "@calcom/lib/server/i18n";
/**
* Location Changed Audit Action Service
@@ -15,8 +15,7 @@ const fieldsSchemaV1 = z.object({
location: StringChangeSchema,
});
export class LocationChangedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class LocationChangedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "LOCATION_CHANGED" as const;
private static dataSchemaV1 = z.object({
@@ -60,8 +59,8 @@ export class LocationChangedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle({ storedData }: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone?: string }): Promise<TranslationWithParams> {
const { fields } = storedData;
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
// TODO: Ideally we want to translate the location label to the user's locale
// We currently don't accept requesting user's translate fn here, fix it later.
const t = await getTranslation("en", "common");
@@ -3,7 +3,7 @@ import { z } from "zod";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService";
/**
* Reassignment Audit Action Service
@@ -20,8 +20,7 @@ const fieldsSchemaV1 = z.object({
title: StringChangeSchema.optional(),
});
export class ReassignmentAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class ReassignmentAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "REASSIGNMENT" as const;
private static dataSchemaV1 = z.object({
@@ -65,8 +64,8 @@ export class ReassignmentAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle({ storedData }: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
const { fields } = storedData;
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const user = await this.userRepository.findById({ id: fields.assignedToId.new });
const reassignedToName = user?.name || "Unknown";
return {
@@ -77,23 +76,19 @@ export class ReassignmentAuditActionService
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): ReassignmentAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): ReassignmentAuditDisplayData {
const { fields } = this.parseStored(storedData);
return {
newAssignedToId: fields.assignedToId.new,
reassignmentReason: fields.reassignmentReason.new ?? null,
};
}
getDisplayFields(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Array<{
getDisplayFields(storedData: BaseStoredAuditData): Array<{
labelKey: string;
valueKey: string;
}> {
const { fields } = storedData;
const typeTranslationKey = `booking_audit_action.assignmentType_${fields.reassignmentType}`;
return [
@@ -2,7 +2,7 @@ import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Rejected Audit Action Service
@@ -15,8 +15,7 @@ const fieldsSchemaV1 = z.object({
status: StringChangeSchema,
});
export class RejectedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class RejectedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "REJECTED" as const;
private static dataSchemaV1 = z.object({
@@ -60,17 +59,14 @@ export class RejectedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.rejected" };
}
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): RejectedAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): RejectedAuditDisplayData {
const { fields } = this.parseStored(storedData);
return {
rejectionReason: fields.rejectionReason.new ?? null,
previousReason: fields.rejectionReason.old ?? null,
@@ -2,7 +2,7 @@ import { z } from "zod";
import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Reschedule Requested Audit Action Service
@@ -16,8 +16,7 @@ const fieldsSchemaV1 = z.object({
rescheduled: BooleanChangeSchema.optional(),
});
export class RescheduleRequestedAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class RescheduleRequestedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "RESCHEDULE_REQUESTED" as const;
private static dataSchemaV1 = z.object({
@@ -61,17 +60,14 @@ export class RescheduleRequestedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.reschedule_requested" };
}
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): RescheduleRequestedAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): RescheduleRequestedAuditDisplayData {
const { fields } = this.parseStored(storedData);
return {
reason: fields.cancellationReason.new ?? null,
requestedBy: fields.cancelledBy.new ?? null,
@@ -2,7 +2,7 @@ import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService";
/**
* Rescheduled Audit Action Service
@@ -16,8 +16,7 @@ const fieldsSchemaV1 = z.object({
rescheduledToUid: StringChangeSchema,
});
export class RescheduledAuditActionService
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
export class RescheduledAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "RESCHEDULED" as const;
private static dataSchemaV1 = z.object({
@@ -64,19 +63,17 @@ export class RescheduledAuditActionService
async getDisplayTitle({
storedData,
userTimeZone,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): Promise<TranslationWithParams> {
const rescheduledToUid = storedData.fields.rescheduledToUid.new;
}: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const rescheduledToUid = fields.rescheduledToUid.new;
const timeZone = userTimeZone;
// Format dates in user timezone
const oldDate = storedData.fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(storedData.fields.startTime.old, timeZone)
const oldDate = fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, timeZone)
: "";
const newDate = storedData.fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(storedData.fields.startTime.new, timeZone)
const newDate = fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, timeZone)
: "";
return {
@@ -92,20 +89,21 @@ export class RescheduledAuditActionService
getDisplayTitleForRescheduledFromLog({
fromRescheduleUid,
userTimeZone,
parsedData,
storedData,
}: {
fromRescheduleUid: string;
userTimeZone: string;
parsedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
storedData: BaseStoredAuditData;
}): TranslationWithParams {
const timeZone = userTimeZone;
const { fields } = this.parseStored(storedData);
// Format dates in user timezone
const oldDate = parsedData.fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(parsedData.fields.startTime.old, timeZone)
const oldDate = fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, timeZone)
: "";
const newDate = parsedData.fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(parsedData.fields.startTime.new, timeZone)
const newDate = fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, timeZone)
: "";
return {
@@ -121,11 +119,8 @@ export class RescheduledAuditActionService
getDisplayJson({
storedData,
userTimeZone,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): RescheduledAuditDisplayData {
const { fields } = storedData;
}: GetDisplayJsonParams): RescheduledAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const timeZone = userTimeZone;
return {
@@ -0,0 +1,91 @@
import { z } from "zod";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { GetDisplayTitleParams, GetDisplayJsonParams, IAuditActionService, TranslationWithParams } from "./IAuditActionService";
/**
* Seat Booked Audit Action Service
* Handles SEAT_BOOKED action with per-action versioning
*
* Note: SEAT_BOOKED action captures initial state, so it doesn't use { old, new } pattern
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
seatReferenceUid: z.string(),
attendeeEmail: z.string(),
attendeeName: z.string(),
startTime: z.number(),
endTime: z.number(),
});
export class SeatBookedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "SEAT_BOOKED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = SeatBookedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = SeatBookedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<typeof SeatBookedAuditActionService.latestFieldsSchema, typeof SeatBookedAuditActionService.storedDataSchema>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: SeatBookedAuditActionService.latestFieldsSchema,
storedDataSchema: SeatBookedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.seat_booked" };
}
getDisplayJson({
storedData,
userTimeZone,
}: GetDisplayJsonParams): SeatBookedAuditDisplayData {
const { fields } = this.parseStored(storedData);
return {
seatReferenceUid: fields.seatReferenceUid,
attendeeEmail: fields.attendeeEmail,
attendeeName: fields.attendeeName,
startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, userTimeZone),
endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, userTimeZone),
};
}
}
export type SeatBookedAuditData = z.infer<typeof fieldsSchemaV1>;
export type SeatBookedAuditDisplayData = {
seatReferenceUid: string;
attendeeEmail: string;
attendeeName: string;
startTime: string;
endTime: string;
};
@@ -0,0 +1,125 @@
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams, TranslationWithParams } from "./IAuditActionService";
/**
* Seat Rescheduled Audit Action Service
* Handles SEAT_RESCHEDULED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
seatReferenceUid: z.string(),
attendeeEmail: z.string(),
startTime: StringChangeSchema,
endTime: StringChangeSchema,
rescheduledToBookingUid: StringChangeSchema,
});
export class SeatRescheduledAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "SEAT_RESCHEDULED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = SeatRescheduledAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = SeatRescheduledAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof SeatRescheduledAuditActionService.latestFieldsSchema,
typeof SeatRescheduledAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: SeatRescheduledAuditActionService.latestFieldsSchema,
storedDataSchema: SeatRescheduledAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle({
storedData,
userTimeZone,
}: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const rescheduledToBookingUid = fields.rescheduledToBookingUid.new;
// Format dates in user timezone
const oldDate = fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, userTimeZone)
: "";
const newDate = fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, userTimeZone)
: "";
return {
key: "booking_audit_action.seat_rescheduled",
params: {
oldDate,
newDate,
},
components: rescheduledToBookingUid ? [{ type: "link", href: `/booking/${rescheduledToBookingUid}/logs` }] : undefined,
};
}
getDisplayJson({
storedData,
userTimeZone,
}: GetDisplayJsonParams): SeatRescheduledAuditDisplayData {
const { fields } = this.parseStored(storedData);
return {
seatReferenceUid: fields.seatReferenceUid,
attendeeEmail: fields.attendeeEmail,
previousStartTime: fields.startTime.old
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.old, userTimeZone)
: null,
newStartTime: fields.startTime.new
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.new, userTimeZone)
: null,
previousEndTime: fields.endTime.old
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.old, userTimeZone)
: null,
newEndTime: fields.endTime.new
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.new, userTimeZone)
: null,
rescheduledToBookingUid: fields.rescheduledToBookingUid.new ?? null,
};
}
}
export type SeatRescheduledAuditData = z.infer<typeof fieldsSchemaV1>;
export type SeatRescheduledAuditDisplayData = {
seatReferenceUid: string;
attendeeEmail: string;
previousStartTime: string | null;
newStartTime: string | null;
previousEndTime: string | null;
newEndTime: string | null;
rescheduledToBookingUid: string | null;
};
@@ -0,0 +1,18 @@
/**
* Maps app slugs to display names for payment apps
* Falls back to slug itself if not in map
*/
const APP_SLUG_TO_NAME: Record<string, string> = {
stripe: "Stripe",
paypal: "Paypal",
alby: "Alby",
hitpay: "HitPay",
btcpayserver: "BTCPayServer",
};
export function getAppNameFromSlug({ appSlug }: { appSlug: string | null }): string {
if (!appSlug) {
return "Unknown App";
}
return APP_SLUG_TO_NAME[appSlug] ?? appSlug;
}
@@ -1,10 +1,11 @@
export type AuditActorType = "USER" | "GUEST" | "ATTENDEE" | "SYSTEM";
export type AuditActorType = "USER" | "GUEST" | "ATTENDEE" | "SYSTEM" | "APP";
type AuditActor = {
id: string;
type: AuditActorType;
userUuid: string | null;
attendeeId: number | null;
credentialId: number | null;
email: string | null;
phone: string | null;
name: string | null;
@@ -12,9 +13,12 @@ type AuditActor = {
}
export interface IAuditActorRepository {
findByUserUuid(userUuid: string): Promise<AuditActor | null>;
findSystemActorOrThrow(): Promise<AuditActor>;
createIfNotExistsUserActor(params: { userUuid: string }): Promise<AuditActor>;
createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null): Promise<AuditActor>;
findByAttendeeId(attendeeId: number): Promise<AuditActor | null>;
createIfNotExistsAttendeeActor(params: { attendeeId: number }): Promise<AuditActor>;
createIfNotExistsGuestActor(params: { email: string | null; name: string | null; phone: string | null }): Promise<AuditActor>;
createIfNotExistsAppActor(params:
| { credentialId: number }
| { email: string; name: string }
): Promise<AuditActor>;
}
@@ -1,5 +1,6 @@
import type { JsonValue } from "@calcom/types/Json";
import type { AuditActorType } from "./IAuditActorRepository";
import type { ActionSource } from "../types/actionSource";
export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED"
@@ -10,7 +11,7 @@ export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DEL
* They are reserved in the enum for potential future use but should not appear in audit logs.
* Use the CREATED action to capture initial booking status instead.
*/
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED"
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED"
export type BookingAuditCreateInput = {
bookingUid: string;
actorId: string;
@@ -18,6 +19,8 @@ export type BookingAuditCreateInput = {
data: JsonValue;
type: BookingAuditType;
timestamp: Date;
source: ActionSource;
operationId: string;
}
type BookingAudit = {
@@ -30,6 +33,8 @@ type BookingAudit = {
createdAt: Date;
updatedAt: Date;
data: JsonValue;
source: ActionSource;
operationId: string;
}
export type BookingAuditWithActor = BookingAudit & {
@@ -38,6 +43,7 @@ export type BookingAuditWithActor = BookingAudit & {
type: AuditActorType;
userUuid: string | null;
attendeeId: number | null;
credentialId: number | null;
name: string | null;
createdAt: Date;
};
@@ -49,6 +55,8 @@ export interface IBookingAuditRepository {
*/
create(bookingAudit: BookingAuditCreateInput): Promise<BookingAudit>;
createMany(bookingAudits: BookingAuditCreateInput[]): Promise<{ count: number }>;
/**
* Retrieves all audit logs for a specific booking
* @param bookingUid - The unique identifier of the booking
@@ -1,8 +1,6 @@
import type { PrismaClient } from "@calcom/prisma/client";
import type { IAuditActorRepository } from "./IAuditActorRepository";
const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
type Dependencies = {
prismaClient: PrismaClient;
}
@@ -14,18 +12,6 @@ export class PrismaAuditActorRepository implements IAuditActorRepository {
});
}
async findSystemActorOrThrow() {
const actor = await this.deps.prismaClient.auditActor.findUnique({
where: { id: SYSTEM_ACTOR_ID },
});
if (!actor) {
throw new Error("System actor not found");
}
return actor;
}
async createIfNotExistsUserActor(params: { userUuid: string }) {
return this.deps.prismaClient.auditActor.upsert({
where: { userUuid: params.userUuid },
@@ -37,7 +23,8 @@ export class PrismaAuditActorRepository implements IAuditActorRepository {
});
}
async createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null) {
async createIfNotExistsGuestActor(params: { email: string | null; name: string | null; phone: string | null }) {
const { email, name, phone } = params;
const normalizedEmail = email && email.trim() !== "" ? email : null;
const normalizedName = name && name.trim() !== "" ? name : null;
const normalizedPhone = phone && phone.trim() !== "" ? phone : null;
@@ -101,9 +88,40 @@ export class PrismaAuditActorRepository implements IAuditActorRepository {
});
}
async findByAttendeeId(attendeeId: number) {
return this.deps.prismaClient.auditActor.findUnique({
where: { attendeeId },
async createIfNotExistsAttendeeActor(params: { attendeeId: number }) {
return this.deps.prismaClient.auditActor.upsert({
where: { attendeeId: params.attendeeId },
create: {
type: "ATTENDEE",
attendeeId: params.attendeeId,
},
update: {},
});
}
async createIfNotExistsAppActor(params:
| { credentialId: number }
| { email: string; name: string }
) {
if ('credentialId' in params) {
return this.deps.prismaClient.auditActor.upsert({
where: { credentialId: params.credentialId },
create: {
type: "APP",
credentialId: params.credentialId,
},
update: {},
});
}
return this.deps.prismaClient.auditActor.upsert({
where: { email: params.email },
create: {
type: "APP",
email: params.email,
name: params.name,
},
update: {},
});
}
@@ -15,10 +15,25 @@ const safeActorSelect = {
type: true,
userUuid: true,
attendeeId: true,
credentialId: true,
name: true,
createdAt: true,
} as const;
const safeBookingAuditSelect = {
id: true,
bookingUid: true,
actorId: true,
action: true,
type: true,
timestamp: true,
source: true,
operationId: true,
data: true,
createdAt: true,
updatedAt: true,
} as const;
export class PrismaBookingAuditRepository implements IBookingAuditRepository {
constructor(private readonly deps: Dependencies) { }
@@ -30,17 +45,36 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
action: bookingAudit.action,
type: bookingAudit.type,
timestamp: bookingAudit.timestamp,
source: bookingAudit.source,
operationId: bookingAudit.operationId,
data: bookingAudit.data === null ? undefined : bookingAudit.data,
},
});
}
async createMany(bookingAudits: BookingAuditCreateInput[]) {
const result = await this.deps.prismaClient.bookingAudit.createMany({
data: bookingAudits.map((bookingAudit) => ({
bookingUid: bookingAudit.bookingUid,
actorId: bookingAudit.actorId,
action: bookingAudit.action,
type: bookingAudit.type,
timestamp: bookingAudit.timestamp,
source: bookingAudit.source,
operationId: bookingAudit.operationId,
data: bookingAudit.data === null ? undefined : bookingAudit.data,
})),
});
return { count: result.count };
}
async findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]> {
return this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
},
include: {
select: {
...safeBookingAuditSelect,
actor: {
select: safeActorSelect,
},
@@ -57,14 +91,13 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
bookingUid,
action: "RESCHEDULED",
},
include: {
select: {
...safeBookingAuditSelect,
actor: {
select: safeActorSelect,
select: safeActorSelect
},
},
orderBy: {
timestamp: "desc",
},
orderBy: { timestamp: "desc" },
});
}
}
@@ -0,0 +1,95 @@
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
export enum BookingAuditErrorCode {
ORGANIZATION_ID_REQUIRED = "ORGANIZATION_ID_REQUIRED",
BOOKING_NOT_FOUND_OR_PERMISSION_DENIED = "BOOKING_NOT_FOUND_OR_PERMISSION_DENIED",
BOOKING_HAS_NO_OWNER = "BOOKING_HAS_NO_OWNER",
OWNER_NOT_IN_ORGANIZATION = "OWNER_NOT_IN_ORGANIZATION",
PERMISSION_DENIED = "PERMISSION_DENIED",
}
export class BookingAuditPermissionError extends Error {
constructor(public readonly code: BookingAuditErrorCode) {
super(code);
this.name = "BookingAuditPermissionError";
}
}
interface BookingAuditAccessServiceDeps {
bookingRepository: BookingRepository;
membershipRepository: MembershipRepository;
}
/**
* BookingAuditAccessService - Service for checking access permissions to booking audit logs
* Audit logs are admin-only for compliance and security purposes.
* Regular users (including booking organizers and hosts) cannot view audit logs.
*/
export class BookingAuditAccessService {
private readonly bookingRepository: BookingRepository;
private readonly membershipRepository: MembershipRepository;
private readonly permissionCheckService: PermissionCheckService;
constructor(deps: BookingAuditAccessServiceDeps) {
this.bookingRepository = deps.bookingRepository;
this.membershipRepository = deps.membershipRepository;
this.permissionCheckService = new PermissionCheckService();
}
/**
* Check if user has permission to view audit logs for a booking
* Throws BookingAuditPermissionError if access is denied
*/
async assertPermissions({ bookingUid, userId, organizationId }: { bookingUid: string, userId: number, organizationId: number | null }): Promise<void> {
if (!organizationId) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED);
}
const booking = await this.bookingRepository.findByUidIncludeEventType({ bookingUid });
if (!booking) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED);
}
const bookingEventType = booking.eventType;
const bookingEventTypeTeamId = bookingEventType?.teamId ?? bookingEventType?.parent?.teamId;
if (bookingEventTypeTeamId) {
const hasAccess = await this.permissionCheckService.checkPermission({
userId,
teamId: bookingEventTypeTeamId,
permission: "booking.readTeamAuditLogs",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (hasAccess) {
return;
}
}
const bookingOwnerId = booking.userId;
if (!bookingOwnerId) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.BOOKING_HAS_NO_OWNER);
}
const isBookingOwnerMemberOfOrganization = await this.membershipRepository.hasMembership({ userId: bookingOwnerId, teamId: organizationId });
if (!isBookingOwnerMemberOfOrganization) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION);
}
const hasAccess = await this.permissionCheckService.checkPermission({
userId,
teamId: organizationId,
permission: "booking.readOrgAuditLogs",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (hasAccess) {
return;
}
throw new BookingAuditPermissionError(BookingAuditErrorCode.PERMISSION_DENIED);
}
}
@@ -15,6 +15,8 @@ import { AttendeeRemovedAuditActionService, type AttendeeRemovedAuditData } from
import { ReassignmentAuditActionService, type ReassignmentAuditData } from "../actions/ReassignmentAuditActionService";
import { LocationChangedAuditActionService, type LocationChangedAuditData } from "../actions/LocationChangedAuditActionService";
import { AttendeeNoShowUpdatedAuditActionService, type AttendeeNoShowUpdatedAuditData } from "../actions/AttendeeNoShowUpdatedAuditActionService";
import { SeatBookedAuditActionService, type SeatBookedAuditData } from "../actions/SeatBookedAuditActionService";
import { SeatRescheduledAuditActionService, type SeatRescheduledAuditData } from "../actions/SeatRescheduledAuditActionService";
/**
* Union type for all audit action data types
@@ -32,8 +34,9 @@ export type AuditActionData =
| AttendeeRemovedAuditData
| ReassignmentAuditData
| LocationChangedAuditData
| AttendeeNoShowUpdatedAuditData;
| AttendeeNoShowUpdatedAuditData
| SeatBookedAuditData
| SeatRescheduledAuditData;
/**
* BookingAuditActionServiceRegistry
@@ -47,12 +50,10 @@ interface BookingAuditActionServiceRegistryDeps {
}
export class BookingAuditActionServiceRegistry {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly actionServices: Map<BookingAuditAction, IAuditActionService<any, any>>;
private readonly actionServices: Map<BookingAuditAction, IAuditActionService>;
constructor(private deps: BookingAuditActionServiceRegistryDeps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const services: Array<[BookingAuditAction, IAuditActionService<any, any>]> = [
const services: Array<[BookingAuditAction, IAuditActionService]> = [
["CREATED", new CreatedAuditActionService()],
["CANCELLED", new CancelledAuditActionService()],
["RESCHEDULED", new RescheduledAuditActionService()],
@@ -65,6 +66,8 @@ export class BookingAuditActionServiceRegistry {
["REASSIGNMENT", new ReassignmentAuditActionService(deps.userRepository)],
["LOCATION_CHANGED", new LocationChangedAuditActionService()],
["ATTENDEE_NO_SHOW_UPDATED", new AttendeeNoShowUpdatedAuditActionService()],
["SEAT_BOOKED", new SeatBookedAuditActionService()],
["SEAT_RESCHEDULED", new SeatRescheduledAuditActionService()],
];
this.actionServices = new Map(services);
}
@@ -73,11 +76,10 @@ export class BookingAuditActionServiceRegistry {
* Get Action Service - Returns the appropriate action service for the given action type
*
* @param action - The booking audit action type
* @returns The corresponding action service instance
* @returns The corresponding action service instance with proper typing
* @throws Error if no service is found for the action
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getActionService(action: BookingAuditAction): IAuditActionService<any, any> {
getActionService(action: BookingAuditAction): IAuditActionService {
const service = this.actionServices.get(action);
if (!service) {
throw new Error(`No action service found for: ${action}`);
@@ -1,7 +1,7 @@
import { z } from "zod";
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask";
import type { ActionSource } from "../types/actionSource";
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
@@ -14,6 +14,8 @@ import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActi
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import { SeatBookedAuditActionService } from "../actions/SeatBookedAuditActionService";
import { SeatRescheduledAuditActionService } from "../actions/SeatRescheduledAuditActionService";
/**
* BookingAuditProducerService Interface
@@ -29,95 +31,158 @@ import { RescheduledAuditActionService } from "../actions/RescheduledAuditAction
* - BookingAuditTriggerProducerService: (Future) Uses Trigger.dev for cloud jobs
*/
export interface BookingAuditProducerService {
/**
* Queue Audit - Legacy method for backwards compatibility with existing code
*
* @deprecated Use specialized methods (queueCreatedAudit, queueCancelledAudit, etc.) instead
*/
queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise<void>;
queueCreatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueCreatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAcceptedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAcceptedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueCancelledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueCancelledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRescheduleRequestedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RescheduleRequestedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRescheduleRequestedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RescheduleRequestedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeAddedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeAddedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeAddedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeAddedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueHostNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueHostNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRejectedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRejectedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeRemovedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeRemovedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeRemovedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeRemovedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueReassignmentAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof ReassignmentAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueReassignmentAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof ReassignmentAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueLocationChangedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof LocationChangedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueLocationChangedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof LocationChangedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueSeatBookedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof SeatBookedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueSeatRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof SeatRescheduledAuditActionService.latestFieldsSchema>;
}): Promise<void>;
/**
* Queues a bulk accepted audit task for multiple bookings
*/
queueBulkAcceptedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void>;
/**
* Queues a bulk cancelled audit task for multiple bookings
*/
queueBulkCancelledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void>;
}
@@ -4,13 +4,18 @@ import logger from "@calcom/lib/logger";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditTaskBasePayload } from "../types/bookingAuditTask";
import { BookingAuditTaskBaseSchema } from "../types/bookingAuditTask";
import { BookingAuditActionServiceRegistry, type AuditActionData } from "./BookingAuditActionServiceRegistry";
import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction } from "../repository/IBookingAuditRepository";
import type { PiiFreeActor } from "../../../bookings/lib/types/actor";
import type {
SingleBookingAuditTaskConsumerPayload,
BulkBookingAuditTaskConsumerPayload,
BookingAuditTaskConsumerPayload
} from "../types/bookingAuditTask";
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction, BookingAuditCreateInput } from "../repository/IBookingAuditRepository";
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
import type { ActionSource } from "../types/actionSource";
import { safeStringify } from "@calcom/lib/safeStringify";
import { Task } from "@calcom/features/tasker/repository";
interface BookingAuditTaskConsumerDeps {
bookingAuditRepository: IBookingAuditRepository;
@@ -24,6 +29,8 @@ type CreateBookingAuditInput = {
actorId: string;
type: BookingAuditType;
action: BookingAuditAction;
source: ActionSource;
operationId: string;
data: JsonValue;
timestamp: Date; // Required: actual time of the booking change (business event)
};
@@ -74,58 +81,53 @@ export class BookingAuditTaskConsumer {
}
/**
* Process Audit Task - Entry point for task handler
*
* This method handles:
* 1. Base schema validation (parses data as unknown)
* 2. Feature flag checks
* 3. Action-specific data validation and migration
* 4. Routing to appropriate action handlers
*
* Two-Stage Parsing:
* - First validates base fields with lean schema (data as unknown)
* - Then validates data with action-specific schema via action service
* - This avoids large discriminated unions for better TypeScript performance
*
* @param payload - The booking audit task payload (unknown type, will be validated)
* @param taskId - Optional task ID for updating migrated payload in DB
* @returns Promise that resolves when processing is complete
* Process single booking Audit Task
*/
async processAuditTask(payload: unknown, taskId: string): Promise<void> {
// Step 1: Parse with lean base schema (data as unknown)
const parseResult = BookingAuditTaskBaseSchema.safeParse(payload);
async processAuditTask(payload: SingleBookingAuditTaskConsumerPayload, taskId: string): Promise<void> {
const { action, bookingUid, actor, organizationId, data, timestamp, source, operationId } = payload;
if (!parseResult.success) {
const errorMsg = `Invalid booking audit payload: ${safeStringify(parseResult.error.errors)}`;
logger.error(errorMsg);
throw new Error(errorMsg);
}
const validatedPayload = parseResult.data;
const { action, bookingUid, actor, organizationId, data, timestamp } = validatedPayload;
// Skip processing for non-organization bookings
if (organizationId === null) {
logger.info(
`Skipping audit for non-organization booking: action=${action}, bookingUid=${bookingUid}`
);
if (!await this.shouldProcessAudit({
organizationId,
action,
bookingUids: [bookingUid],
})) {
return;
}
const isFeatureEnabled = await this.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit");
const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload, taskId });
if (!isFeatureEnabled) {
logger.info(
`booking-audit feature is disabled for organization, skipping audit processing: action=${action}, bookingUid=${bookingUid}, organizationId=${organizationId}`
);
await this.onBookingAction({ bookingUid, actor, action, source, operationId, data: dataInLatestFormat, timestamp });
}
/**
* Process Bulk bookings Audit Task
*/
async processBulkAuditTask(payload: BulkBookingAuditTaskConsumerPayload, taskId: string): Promise<void> {
const { bookings, action, actor, organizationId, timestamp, source, operationId } = payload;
if (!await this.shouldProcessAudit({
organizationId,
action,
bookingUids: bookings.map((booking) => booking.bookingUid),
})) {
return;
}
// Step 2: Validate and migrate data with action-specific schema
const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload: validatedPayload, taskId });
const migratedBookings = await this.bulkMigrateIfNeeded({
bookings,
action,
payload,
taskId,
});
// dataInLatestFormat is validated by action-specific schema in migrateIfNeeded
await this.onBookingAction({ bookingUid, actor, action, data: dataInLatestFormat as AuditActionData, timestamp });
await this.onBulkBookingActions({
bookings: migratedBookings,
actor,
action,
source,
operationId,
timestamp,
});
}
/**
@@ -142,7 +144,7 @@ export class BookingAuditTaskConsumer {
private async migrateIfNeeded(params: {
action: BookingAuditAction;
data: unknown;
payload: BookingAuditTaskBasePayload;
payload: SingleBookingAuditTaskConsumerPayload;
taskId: string;
}) {
const { action, data, payload, taskId } = params;
@@ -173,33 +175,124 @@ export class BookingAuditTaskConsumer {
* @param taskId - Task ID (required for DB update)
*/
private async updateTaskPayload(
payload: BookingAuditTaskBasePayload,
payload: BookingAuditTaskConsumerPayload,
latestData: unknown,
taskId: string
): Promise<void> {
try {
const { Task } = await import("@calcom/features/tasker/repository");
const updatedPayload = { ...payload, data: latestData };
await Task.updatePayload(taskId, JSON.stringify(updatedPayload));
logger.info(
`Successfully updated task payload in DB: taskId=${taskId}, action=${payload.action}`
);
} catch (error) {
// Log error but don't fail the task - migration happened in memory
logger.error(
// Warning because nothing functionally failed, we have a risk in future that if we completely remove a version support, the particular task if retried will fail with a schema error.
logger.warn(
`Failed to update task payload in DB: taskId=${taskId}, error=${safeStringify(error)}`
);
}
}
/**
* Bulk Migrate If Needed - Migrates all bookings in a bulk operation
*
* Since all bookings in a bulk operation share the same action type,
* they all use the same action service and schema version.
* If any booking is migrated, updates the DB task payload for retry consistency.
*
* @param params - Bookings, action, payload, and task ID
* @returns Array of migrated bookings with latest data
*/
private async bulkMigrateIfNeeded(params: {
bookings: BulkBookingAuditTaskConsumerPayload["bookings"];
action: BookingAuditAction;
payload: BulkBookingAuditTaskConsumerPayload;
taskId: string;
}): Promise<Array<{ bookingUid: string; data: Record<string, unknown> }>> {
const { bookings, action, payload, taskId } = params;
const actionService = this.actionServiceRegistry.getActionService(action);
let anyMigrated = false;
const migratedBookings = await Promise.all(
bookings.map(async (booking) => {
const migrationResult = actionService.migrateToLatest(booking.data);
if (migrationResult.isMigrated) {
anyMigrated = true;
}
return {
bookingUid: booking.bookingUid,
data: migrationResult.latestData,
};
})
);
if (anyMigrated) {
logger.info(`Schema migration performed for bulk audit: action=${action}`);
await this.updateBulkTaskPayload(payload, migratedBookings, taskId);
}
return migratedBookings;
}
private async updateBulkTaskPayload(
payload: BulkBookingAuditTaskConsumerPayload,
migratedBookings: Array<{ bookingUid: string; data: Record<string, unknown> }>,
taskId: string
): Promise<void> {
try {
const updatedPayload = { ...payload, bookings: migratedBookings };
await Task.updatePayload(taskId, JSON.stringify(updatedPayload));
logger.info(
`Successfully updated bulk task payload in DB: taskId=${taskId}, action=${payload.action}`
);
} catch (error) {
// Warning because nothing functionally failed, we have a risk in future that if we completely remove a version support, the particular task if retried will fail with a schema error.
logger.warn(
`Failed to update bulk task payload in DB: taskId=${taskId}, error=${safeStringify(error)}`
);
}
}
/**
* Check if audit should be processed based on organization and feature flag
*
* @param params - Organization ID, action, and context for logging
* @returns true if audit should be processed, false otherwise
*/
private async shouldProcessAudit(params: {
organizationId: number | null;
action: BookingAuditAction;
bookingUids: string[];
}): Promise<boolean> {
const { organizationId, action, bookingUids } = params;
if (organizationId === null) {
logger.info(`Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}`);
return false;
}
const isFeatureEnabled = await this.featuresRepository.checkIfTeamHasFeature(
organizationId,
"booking-audit"
);
if (!isFeatureEnabled) {
logger.info(
`booking-audit feature is disabled for organization: action=${action}, bookingUids=${bookingUids.join(",")}, organizationId=${organizationId}`
);
return false;
}
return true;
}
/**
* Resolves an Actor to an actor ID in the AuditActor table
* Handles different actor types appropriately (upsert, lookup, or direct ID)
*/
private async resolveActorId(actor: Actor): Promise<string> {
private async resolveActorId(actor: PiiFreeActor): Promise<string> {
switch (actor.identifiedBy) {
case "id":
return actor.id;
@@ -208,20 +301,9 @@ export class BookingAuditTaskConsumer {
return userActor.id;
}
case "attendee": {
const attendeeActor = await this.auditActorRepository.findByAttendeeId(actor.attendeeId);
if (!attendeeActor) {
throw new Error(`Attendee actor not found for attendeeId: ${actor.attendeeId}`);
}
const attendeeActor = await this.auditActorRepository.createIfNotExistsAttendeeActor({ attendeeId: actor.attendeeId });
return attendeeActor.id;
}
case "guest": {
const guestActor = await this.auditActorRepository.createIfNotExistsGuestActor(
actor.email ?? null,
actor.name ?? null,
actor.phone ?? null
);
return guestActor.id;
}
}
}
@@ -235,6 +317,7 @@ export class BookingAuditTaskConsumer {
actorId: input.actorId,
type: input.type,
action: input.action,
source: input.source,
timestamp: input.timestamp,
}));
@@ -243,7 +326,9 @@ export class BookingAuditTaskConsumer {
actorId: input.actorId,
type: input.type,
action: input.action,
source: input.source,
timestamp: input.timestamp,
operationId: input.operationId,
data: input.data ?? null,
});
}
@@ -262,7 +347,7 @@ export class BookingAuditTaskConsumer {
private getRecordType(params: { action: BookingAuditAction }): BookingAuditType {
const { action } = params;
switch (action) {
case "CREATED":
return "RECORD_CREATED";
@@ -279,6 +364,8 @@ export class BookingAuditTaskConsumer {
case "LOCATION_CHANGED":
case "HOST_NO_SHOW_UPDATED":
case "ATTENDEE_NO_SHOW_UPDATED":
case "SEAT_BOOKED":
case "SEAT_RESCHEDULED":
return "RECORD_UPDATED";
// PENDING and AWAITING_HOST are not implemented as they represent initial states
@@ -288,14 +375,51 @@ export class BookingAuditTaskConsumer {
}
}
private async onBulkBookingActions(params: {
bookings: Array<{ bookingUid: string; data: Record<string, unknown> }>;
actor: PiiFreeActor;
action: BookingAuditAction;
source: ActionSource;
operationId: string;
timestamp: number;
}): Promise<void> {
const { bookings, actor, action, source, operationId, timestamp } = params;
const actorId = await this.resolveActorId(actor);
const recordType = this.getRecordType({ action });
const actionService = this.actionServiceRegistry.getActionService(action);
const auditRecordsToCreate: BookingAuditCreateInput[] = bookings.map((booking) => {
const versionedData = actionService.getVersionedData(booking.data);
return {
bookingUid: booking.bookingUid,
actorId,
type: recordType,
action,
source,
operationId,
data: versionedData as JsonValue,
timestamp: new Date(timestamp),
};
});
await this.bookingAuditRepository.createMany(auditRecordsToCreate);
logger.info(
`Successfully created ${auditRecordsToCreate.length} bulk audit records: action=${action}, operationId=${operationId}`
);
}
async onBookingAction(params: {
bookingUid: string;
actor: Actor;
actor: PiiFreeActor;
action: BookingAuditAction;
data: AuditActionData;
source: ActionSource;
operationId: string;
data: Record<string, unknown>;
timestamp: number;
}): Promise<BookingAudit> {
const { bookingUid, actor, action, data, timestamp } = params;
const { bookingUid, actor, action, source, operationId, data, timestamp } = params;
const actionService = this.actionServiceRegistry.getActionService(action);
const versionedData = actionService.getVersionedData(data);
const actorId = await this.resolveActorId(actor);
@@ -306,6 +430,8 @@ export class BookingAuditTaskConsumer {
actorId,
type: recordType,
action,
source,
operationId,
// versionedData is { version: number; fields: unknown } which is JsonValue-compatible
data: versionedData as JsonValue,
timestamp: new Date(timestamp),
@@ -1,11 +1,14 @@
import { z } from "zod";
import { v4 as uuidv4 } from "uuid";
import type { Tasker } from "@calcom/features/tasker/tasker";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditAction, BookingAuditTaskProducerActionData } from "../types/bookingAuditTask";
import type { BookingAuditAction } from "../types/bookingAuditTask";
import type { ActionSource } from "../types/actionSource";
import { makeActorById, type PiiFreeActor, type Actor, buildActorEmail } from "../../../bookings/lib/types/actor";
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
@@ -18,11 +21,14 @@ import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActi
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import { SeatBookedAuditActionService } from "../actions/SeatBookedAuditActionService";
import { SeatRescheduledAuditActionService } from "../actions/SeatRescheduledAuditActionService";
import type { BookingAuditProducerService } from "./BookingAuditProducerService.interface";
interface BookingAuditTaskerProducerServiceDeps {
tasker: Tasker;
log: ISimpleLogger;
auditActorRepository: IAuditActorRepository;
}
/**
@@ -37,41 +43,60 @@ interface BookingAuditTaskerProducerServiceDeps {
export class BookingAuditTaskerProducerService implements BookingAuditProducerService {
private readonly tasker: Tasker;
private readonly log: BookingAuditTaskerProducerServiceDeps["log"];
private readonly auditActorRepository: IAuditActorRepository;
constructor(private readonly deps: BookingAuditTaskerProducerServiceDeps) {
this.tasker = deps.tasker;
this.log = deps.log;
this.auditActorRepository = deps.auditActorRepository;
}
/**
* Queue Audit - Legacy method for backwards compatibility
*
* @deprecated Use specialized methods (queueCreatedAudit, queueCancelledAudit, etc.) instead
*/
async queueAudit(
bookingUid: string,
actor: Actor,
organizationId: number | null,
actionData: BookingAuditTaskProducerActionData
): Promise<void> {
await this.queueTask({
bookingUid,
actor,
organizationId,
action: actionData.action,
data: actionData.data,
private async getPIIFreeBookingAuditActor(params: {
actor: Actor;
}): Promise<PiiFreeActor> {
const { actor } = params;
if (actor.identifiedBy === "user" || actor.identifiedBy === "attendee" || actor.identifiedBy === "id") {
return actor;
}
if (actor.identifiedBy === "app") {
const piiFreeActor = await this.auditActorRepository.createIfNotExistsAppActor({
credentialId: actor.credentialId,
});
return makeActorById(piiFreeActor.id);
}
if (actor.identifiedBy === "appSlug") {
const email = buildActorEmail({ identifier: actor.appSlug, actorType: "app" });
const piiFreeActor = await this.auditActorRepository.createIfNotExistsAppActor({
email,
name: actor.name,
});
return makeActorById(piiFreeActor.id);
}
// Must be guest actor at this point
const piiFreeActor = await this.auditActorRepository.createIfNotExistsGuestActor({
email: actor.email,
name: actor.name ?? null,
phone: null,
});
return makeActorById(piiFreeActor.id);
}
/**
* Internal helper to queue audit task to Tasker
* @param params.action - Must be a valid BookingAuditAction value (TYPE from action services are string-typed)
* @param params.operationId - Optional operation ID for correlating bulk operations. If null, will be auto-generated.
*/
private async queueTask(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
action: string;
source: ActionSource;
operationId?: string | null;
data: unknown;
}): Promise<void> {
// Skip queueing for non-organization bookings
@@ -82,13 +107,21 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
return;
}
try {
// Cast action to BookingAuditAction since action service TYPE constants are typed as string
await this.tasker.create("bookingAudit", {
bookingUid: params.bookingUid,
const piiFreeActor = await this.getPIIFreeBookingAuditActor({
actor: params.actor,
});
const operationId = params.operationId ?? uuidv4();
await this.tasker.create("bookingAudit", {
isBulk: false,
bookingUid: params.bookingUid,
actor: piiFreeActor,
organizationId: params.organizationId,
timestamp: Date.now(),
action: params.action as BookingAuditAction,
source: params.source,
operationId,
data: params.data,
});
} catch (error) {
@@ -100,6 +133,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -112,6 +147,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -124,6 +161,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -136,6 +175,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -148,6 +189,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RescheduleRequestedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -160,6 +203,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeAddedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -172,6 +217,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -184,6 +231,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -196,6 +245,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeRemovedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -208,6 +259,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof ReassignmentAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -220,6 +273,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof LocationChangedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -232,6 +287,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
@@ -239,4 +296,104 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
action: AttendeeNoShowUpdatedAuditActionService.TYPE,
});
}
async queueSeatBookedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof SeatBookedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: SeatBookedAuditActionService.TYPE,
});
}
async queueSeatRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof SeatRescheduledAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: SeatRescheduledAuditActionService.TYPE,
});
}
private async queueBulkTask(params: {
bookings: Array<{
bookingUid: string;
data: unknown;
}>;
actor: Actor;
organizationId: number | null;
action: string;
source: ActionSource;
operationId?: string | null;
}): Promise<void> {
// Skip queueing for non-organization bookings
if (params.organizationId === null) {
return;
}
if (IS_PRODUCTION) {
return;
}
try {
const piiFreeActor = await this.getPIIFreeBookingAuditActor({
actor: params.actor,
});
const operationId = params.operationId ?? uuidv4();
await this.tasker.create("bookingAudit", {
isBulk: true,
bookings: params.bookings,
actor: piiFreeActor,
organizationId: params.organizationId,
timestamp: Date.now(),
action: params.action as BookingAuditAction,
source: params.source,
operationId,
});
} catch (error) {
this.log.error(`Error while queueing bulk ${params.action} audit`, safeStringify(error));
}
}
async queueBulkAcceptedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: AcceptedAuditActionService.TYPE,
});
}
async queueBulkCancelledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: CancelledAuditActionService.TYPE,
});
}
}
@@ -1,19 +1,26 @@
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
import { BookingAuditAccessService } from "./BookingAuditAccessService";
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository";
import type { AuditActorType } from "../repository/IAuditActorRepository";
import type { TranslationWithParams } from "../actions/IAuditActionService";
import type { ActionSource } from "../types/actionSource";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getAppNameFromSlug } from "../getAppNameFromSlug";
interface BookingAuditViewerServiceDeps {
bookingAuditRepository: IBookingAuditRepository;
userRepository: UserRepository;
bookingRepository: BookingRepository;
membershipRepository: MembershipRepository;
attendeeRepository: IAttendeeRepository;
log: ISimpleLogger;
credentialRepository: CredentialRepository;
}
type EnrichedAuditLog = {
@@ -23,9 +30,11 @@ type EnrichedAuditLog = {
action: BookingAuditAction;
timestamp: string;
createdAt: string;
data: Record<string, unknown> | null;
source: ActionSource;
operationId: string;
displayJson?: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey: string }>;
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
actor: {
id: string;
type: AuditActorType;
@@ -47,16 +56,26 @@ export class BookingAuditViewerService {
private readonly bookingAuditRepository: IBookingAuditRepository;
private readonly userRepository: UserRepository;
private readonly bookingRepository: BookingRepository;
private readonly membershipRepository: MembershipRepository;
private readonly attendeeRepository: IAttendeeRepository;
private readonly credentialRepository: CredentialRepository;
private readonly rescheduledAuditActionService: RescheduledAuditActionService;
private readonly accessService: BookingAuditAccessService;
private readonly log: BookingAuditViewerServiceDeps["log"];
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
this.userRepository = deps.userRepository;
this.bookingRepository = deps.bookingRepository;
this.membershipRepository = deps.membershipRepository;
this.attendeeRepository = deps.attendeeRepository;
this.credentialRepository = deps.credentialRepository;
this.log = deps.log;
this.rescheduledAuditActionService = new RescheduledAuditActionService();
this.accessService = new BookingAuditAccessService({
bookingRepository: this.bookingRepository,
membershipRepository: this.membershipRepository,
});
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
}
@@ -68,21 +87,24 @@ export class BookingAuditViewerService {
* fetches the last RESCHEDULED log from the previous booking and includes it
* as the first log entry with "rescheduled from" context.
*/
async getAuditLogsForBooking(
bookingUid: string,
_userId: number,
_userEmail: string,
userTimeZone?: string
): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> {
await this.checkPermissions();
async getAuditLogsForBooking(params: {
bookingUid: string;
userId: number;
userEmail: string;
userTimeZone: string;
organizationId: number | null;
}): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> {
const { bookingUid, userId, userTimeZone, organizationId } = params;
await this.accessService.assertPermissions({
bookingUid,
userId,
organizationId
});
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
// Default to UTC if no timezone provided (backwards compatibility)
const timezone = userTimeZone ?? "UTC";
const enrichedAuditLogs = await Promise.all(
auditLogs.map((log) => this.enrichAuditLog(log, timezone))
auditLogs.map((log) => this.enrichAuditLog(log, userTimeZone))
);
const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid);
@@ -92,7 +114,7 @@ export class BookingAuditViewerService {
const rescheduledFromLog = await this.buildRescheduledFromLog({
fromRescheduleUid,
currentBookingUid: bookingUid,
userTimeZone: timezone,
userTimeZone,
});
if (rescheduledFromLog) {
// Add the rescheduled log from the previous booking as the first entry
@@ -102,7 +124,7 @@ export class BookingAuditViewerService {
}
return {
bookingUid,
bookingUid: params.bookingUid,
auditLogs: enrichedAuditLogs,
};
}
@@ -118,15 +140,13 @@ export class BookingAuditViewerService {
const actionDisplayTitle = await actionService.getDisplayTitle({ storedData: parsedData, userTimeZone });
// Get display data - use custom getDisplayJson if available, otherwise use raw fields
const displayData = (actionService.getDisplayJson
const displayJson = actionService.getDisplayJson
? actionService.getDisplayJson({ storedData: parsedData, userTimeZone })
: parsedData.fields) as Record<string, unknown> | null;
: null;
// Get display fields - use custom getDisplayFields if available
const displayFields = actionService.getDisplayFields
? actionService.getDisplayFields(parsedData)
: undefined;
: null;
return {
id: log.id,
@@ -135,7 +155,9 @@ export class BookingAuditViewerService {
action: log.action,
timestamp: log.timestamp.toISOString(),
createdAt: log.createdAt.toISOString(),
data: displayData,
source: log.source,
operationId: log.operationId,
displayJson,
actionDisplayTitle,
displayFields,
actor: {
@@ -184,37 +206,29 @@ export class BookingAuditViewerService {
const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone);
const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data);
// Transform the display data to show "rescheduled from" instead of "rescheduled to"
// Transform the display JSON to show "rescheduled from" instead of "rescheduled to"
// by replacing rescheduledToUid with rescheduledFromUid
const transformedData = {
...enrichedLog.data,
rescheduledFromUid: fromRescheduleUid,
};
const transformedDisplayJson = enrichedLog.displayJson
? {
...enrichedLog.displayJson,
rescheduledFromUid: fromRescheduleUid,
}
: undefined;
return {
...enrichedLog,
// Override bookingUid to associate with the current booking being viewed
bookingUid: currentBookingUid,
data: transformedData,
displayJson: transformedDisplayJson,
// Use a different translation key to show "Rescheduled from" instead of "Rescheduled"
actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({
fromRescheduleUid,
userTimeZone,
parsedData,
storedData: parsedData,
}),
};
}
/**
* Check if user has permission to view audit logs for a booking
*/
private async checkPermissions(): Promise<void> {
// TODO: Implement permission check
if (IS_PRODUCTION) {
throw new Error("Permission check is not implemented for production environments");
}
}
/**
* Enrich actor information with user details if userUuid exists
*/
@@ -223,54 +237,85 @@ export class BookingAuditViewerService {
displayEmail: string | null;
displayAvatar: string | null;
}> {
// SYSTEM actor
if (actor.type === "SYSTEM") {
return {
displayName: "Cal.com",
displayEmail: null,
displayAvatar: null,
};
}
// GUEST actor - use name or default
if (actor.type === "GUEST") {
return {
displayName: actor.name || "Guest",
displayEmail: null,
displayAvatar: null,
};
}
// ATTENDEE actor - use name or default
if (actor.type === "ATTENDEE") {
return {
displayName: actor.name || "Attendee",
displayEmail: null,
displayAvatar: null,
};
}
// USER actor - lookup from User table and include avatar
if (actor.type === "USER") {
if (!actor.userUuid) {
throw new Error("User UUID is required for USER actor");
}
const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid });
if (actorUser) {
switch (actor.type) {
case "SYSTEM":
return {
displayName: actorUser.name || actorUser.email,
displayEmail: actorUser.email,
displayAvatar: actorUser.avatarUrl || null,
displayName: "Cal.com",
displayEmail: null,
displayAvatar: null,
};
case "GUEST":
return {
displayName: actor.name || "Guest",
displayEmail: null,
displayAvatar: null,
};
case "APP": {
if (actor.credentialId) {
const credential = await this.deps.credentialRepository.findByCredentialId(actor.credentialId);
if (credential) {
return {
displayName: getAppNameFromSlug({ appSlug: credential.appId }),
displayEmail: null,
displayAvatar: null,
};
} else {
return {
// Expect that on Credential deletion name would have been set
displayName: actor.name ?? "Deleted App",
displayEmail: null,
displayAvatar: null,
};
}
}
// We allow creating App actor without credentialId
return {
displayName: actor.name ?? "Unknown App",
// We don't want to show email for App actor as that is an internal email with the purpose of giving uniqueness to each app actor
displayEmail: null,
displayAvatar: null,
};
}
return {
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
case "ATTENDEE": {
if (!actor.attendeeId) {
throw new Error("Attendee ID is required for ATTENDEE actor");
}
const attendee = await this.attendeeRepository.findById(actor.attendeeId);
if (attendee) {
return {
displayName: attendee.name || attendee.email,
displayEmail: attendee.email,
displayAvatar: null,
};
}
return {
displayName: "Deleted Attendee",
displayEmail: null,
displayAvatar: null,
};
}
case "USER": {
if (!actor.userUuid) {
throw new Error("User UUID is required for USER actor");
}
const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid });
if (actorUser) {
return {
displayName: actorUser.name || actorUser.email,
displayEmail: actorUser.email,
displayAvatar: actorUser.avatarUrl || null,
};
}
return {
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
};
}
}
// Satisfying Typescript
throw new Error(`Unknown actor type: ${actor.type}`);
}
}
@@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { MembershipRole } from "@calcom/prisma/enums";
import { BookingAuditAccessService, BookingAuditErrorCode, BookingAuditPermissionError } from "../BookingAuditAccessService";
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/features/bookings/repositories/BookingRepository");
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
const DB = {
bookings: {} as Record<string, {
uid: string;
userId: number | null;
eventType: {
teamId: number | null;
parent: { teamId: number } | null;
hosts: never[];
users: never[];
};
user: { id: number; email: string };
attendees: never[];
}>,
memberships: {} as Record<string, boolean>,
}
const createMockTeamBooking = (overrides: {
bookingUid: string;
userId?: number;
teamId?: number | null;
parentTeamId?: number;
}) => {
const booking = {
uid: overrides.bookingUid,
userId: overrides?.userId ?? 456,
eventType: {
teamId: (overrides && "teamId" in overrides ? overrides.teamId : overrides?.teamId ?? 100) ?? null,
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
hosts: [],
users: []
},
user: {
id: overrides?.userId ?? 456,
email: "test@example.com",
},
attendees: [],
}
DB.bookings[booking.uid] = booking;
return booking;
}
const createMockPersonalBooking = (overrides: { userId?: number; bookingUid: string }) => {
const booking = {
uid: overrides.bookingUid,
userId: overrides?.userId ?? 456,
eventType: {
teamId: null,
parent: null,
hosts: [],
users: [],
},
attendees: [],
user: {
id: overrides?.userId ?? 456,
email: "test@example.com",
},
};
DB.bookings[booking.uid] = booking;
return booking;
}
const createMockMembership = ({ userId, teamId }: { userId: number; teamId: number }) => {
const key = `${userId}-${teamId}`;
DB.memberships[key] = true;
};
type MockPermissionCheckService = {
checkPermission: Mock<PermissionCheckService["checkPermission"]>;
}
const provideReadTeamAuditLogsPermission = ({ mockPermissionCheckService, value, targetUserId, targetTeamId }: { mockPermissionCheckService: MockPermissionCheckService, value: boolean, targetUserId: number, targetTeamId: number }) => {
mockPermissionCheckService.checkPermission.mockImplementation(({ userId, teamId, permission, _fallbackRoles }) => {
if (permission === "booking.readTeamAuditLogs" && userId === targetUserId && teamId === targetTeamId) {
return Promise.resolve(value);
}
return Promise.resolve(false);
});
}
const provideReadOrgAuditLogsPermission = ({ mockPermissionCheckService, value, targetUserId, targetTeamId }: { mockPermissionCheckService: MockPermissionCheckService, value: boolean, targetUserId: number, targetTeamId: number }) => {
mockPermissionCheckService.checkPermission.mockImplementation(({ userId, teamId, permission, _fallbackRoles }) => {
if (permission === "booking.readOrgAuditLogs" && userId === targetUserId && teamId === targetTeamId) {
return Promise.resolve(value);
}
return Promise.resolve(false);
});
}
const mockBookingRepository: {
findByUidIncludeEventType: Mock<BookingRepository["findByUidIncludeEventType"]>;
} = {
findByUidIncludeEventType: vi.fn().mockImplementation(({ bookingUid }) => {
return Promise.resolve(DB.bookings[bookingUid] ?? null);
}),
};
const mockMembershipRepository: {
hasMembership: Mock<MembershipRepository["hasMembership"]>;
} = {
hasMembership: vi.fn().mockImplementation(({ userId, teamId }) => {
const key = `${userId}-${teamId}`;
return Promise.resolve(DB.memberships[key] ?? false);
}),
};
describe("BookingAuditAccessService - Permission Checks", () => {
let service: BookingAuditAccessService;
let mockPermissionCheckService: MockPermissionCheckService;
beforeEach(() => {
vi.clearAllMocks();
// Clear in-memory DB
DB.bookings = {};
DB.memberships = {};
mockPermissionCheckService = {
checkPermission: vi.fn(),
};
vi.mocked(BookingRepository).mockImplementation(() => mockBookingRepository as unknown as BookingRepository);
vi.mocked(MembershipRepository).mockImplementation(() => mockMembershipRepository as unknown as MembershipRepository);
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as unknown as PermissionCheckService);
service = new BookingAuditAccessService({
bookingRepository: mockBookingRepository as unknown as BookingRepository,
membershipRepository: mockMembershipRepository as unknown as MembershipRepository,
});
});
describe("assertPermissions - Team Bookings", () => {
it("should grant access when user has booking.readTeamAuditLogs permission for the booking's team", async () => {
const bookingUid = "test-booking-uid";
const userId = 123;
const teamId = 100;
createMockTeamBooking({ teamId, bookingUid });
provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: teamId });
await expect(service.assertPermissions({ bookingUid, userId, organizationId: 200 })).resolves.not.toThrow();
});
it("should throw PERMISSION_DENIED error when user lacks booking.readTeamAuditLogs permission and also doesn't even have a membership in the organization for the booking's team", async () => {
const bookingUid = "test-booking-uid";
const userId = 123;
const teamId = 100;
const organizationId = 200;
createMockTeamBooking({ teamId, bookingUid, userId: 456 });
createMockMembership({ userId: 456, teamId: organizationId });
provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: teamId });
provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: organizationId });
const promise = service.assertPermissions({ bookingUid, userId, organizationId });
await expect(promise).rejects.toThrow(BookingAuditPermissionError);
await expect(promise).rejects.toThrow(BookingAuditErrorCode.PERMISSION_DENIED);
});
});
describe("assertPermissions - Organization Bookings (Personal)", () => {
it("should grant access to personal bookings when user is an org member and has booking.readOrgAuditLogs permission for the organization", async () => {
const bookingUid = "test-booking-uid";
const userId = 123;
const organizationId = 200;
createMockPersonalBooking({ userId: 456, bookingUid });
createMockMembership({ userId: 456, teamId: organizationId });
provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: organizationId });
await service.assertPermissions({ bookingUid, userId, organizationId });
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
userId,
teamId: organizationId,
permission: "booking.readOrgAuditLogs",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
});
});
describe("assertPermissions - Managed Events", () => {
it("should grant access to managed event's booking when user has booking.readTeamAuditLogs permission on parent event's team", async () => {
const bookingUid = "test-booking-uid";
const userId = 123;
const parentTeamId = 500;
createMockTeamBooking({ teamId: null, parentTeamId, bookingUid });
provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: parentTeamId });
await service.assertPermissions({ bookingUid, userId, organizationId: 200 });
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
userId,
teamId: parentTeamId,
permission: "booking.readTeamAuditLogs",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
});
it("should throw error when user lacks booking.readTeamAuditLogs permission on parent team", async () => {
const bookingUid = "test-booking-uid";
const userId = 123;
const parentTeamId = 500;
const organizationId = 200;
createMockTeamBooking({ teamId: null, parentTeamId, bookingUid, userId: 456 });
createMockMembership({ userId: 456, teamId: organizationId });
provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: parentTeamId });
provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: organizationId });
await expect(service.assertPermissions({ bookingUid, userId, organizationId })).rejects.toThrow(BookingAuditPermissionError);
});
});
describe("assertPermissions - Edge Cases", () => {
it("should throw error when organizationId is null", async () => {
await expect(service.assertPermissions({ bookingUid: "test-booking-uid", userId: 123, organizationId: null })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid: "test-booking-uid", userId: 123, organizationId: null })).rejects.toThrow(BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED);
});
it("should throw error when booking not found", async () => {
const bookingUid = "non-existent-booking-uid";
// Don't create any booking in DB
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED);
});
it("should throw error when booking has no userId", async () => {
const bookingUid = "test-booking-uid";
const booking = {
uid: bookingUid,
userId: null,
eventType: {
teamId: null,
parent: null,
hosts: [],
users: [],
},
attendees: [],
user: {
id: 456,
email: "test@example.com",
},
};
DB.bookings[bookingUid] = booking;
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.BOOKING_HAS_NO_OWNER);
});
it("should throw error when booking owner is not member of organization", async () => {
const bookingUid = "test-booking-uid";
createMockPersonalBooking({ userId: 456, bookingUid });
// Don't create membership for userId 456 in organization 200
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION);
});
});
});
@@ -0,0 +1,892 @@
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import { BookingAuditViewerService } from "../BookingAuditViewerService";
import { BookingAuditPermissionError, BookingAuditErrorCode } from "../BookingAuditAccessService";
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../../repository/IBookingAuditRepository";
import type { AuditActorType } from "../../repository/IAuditActorRepository";
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/features/users/repositories/UserRepository");
vi.mock("@calcom/features/bookings/repositories/BookingRepository");
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
const createMockTeamBooking = (overrides?: {
userId?: number;
teamId?: number | null;
parentTeamId?: number;
}) => ({
userId: overrides?.userId ?? 456,
user: {
id: overrides?.userId ?? 456,
email: "test@example.com",
},
eventType: {
teamId: (overrides && "teamId" in overrides ? overrides.teamId : overrides?.teamId ?? 100) ?? null,
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
hosts: [],
users: [],
},
attendees: [],
});
const createMockAuditLog = (
overrides?: Partial<{
id: string;
bookingUid: string;
action: BookingAuditAction;
type: BookingAuditType;
timestamp: Date;
createdAt: Date;
updatedAt: Date;
data: unknown;
actorId: string;
actorType: AuditActorType;
actorUserUuid: string | null;
actorName: string | null;
}>
): BookingAuditWithActor => ({
id: overrides?.id ?? "audit-log-1",
bookingUid: overrides?.bookingUid ?? "booking-uid-123",
action: overrides?.action ?? "CREATED",
type: overrides?.type ?? "RECORD_CREATED",
timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"),
createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"),
updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"),
actorId: overrides?.actorId ?? "actor-1",
data: overrides?.data ?? { version: 1, fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" } },
source: "WEBAPP" as const,
operationId: "operation-id-123",
actor: {
id: overrides?.actorId ?? "actor-1",
type: overrides?.actorType ?? "USER" as const,
userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as string | null,
attendeeId: null,
name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null,
createdAt: new Date("2024-01-01T00:00:00Z"),
},
});
const createMockUser = (overrides?: Partial<{ id: number; name: string | null; email: string; avatarUrl: string | null }>) => ({
id: overrides?.id ?? 123,
name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null,
email: overrides?.email ?? "john@example.com",
avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null,
});
describe("BookingAuditViewerService - Integration Tests", () => {
let service: BookingAuditViewerService;
let mockBookingRepository: {
findByUidIncludeEventType: Mock<BookingRepository["findByUidIncludeEventType"]>;
getFromRescheduleUid: Mock<BookingRepository["getFromRescheduleUid"]>;
};
let mockUserRepository: {
getUserOrganizationAndTeams: Mock<UserRepository["getUserOrganizationAndTeams"]>;
findByUuid: Mock<UserRepository["findByUuid"]>;
};
let mockBookingAuditRepository: {
create: Mock<IBookingAuditRepository["create"]>;
findAllForBooking: Mock<IBookingAuditRepository["findAllForBooking"]>;
findRescheduledLogsOfBooking: Mock<IBookingAuditRepository["findRescheduledLogsOfBooking"]>;
};
let mockMembershipRepository: {
hasMembership: Mock<MembershipRepository["hasMembership"]>;
};
let mockPermissionCheckService: {
checkPermission: Mock<PermissionCheckService["checkPermission"]>;
};
let mockAttendeeRepository: {
findById: Mock;
};
let mockLog: {
error: Mock;
};
beforeEach(() => {
vi.clearAllMocks();
mockBookingRepository = {
findByUidIncludeEventType: vi.fn(),
getFromRescheduleUid: vi.fn(),
};
mockUserRepository = {
getUserOrganizationAndTeams: vi.fn(),
findByUuid: vi.fn(),
};
mockBookingAuditRepository = {
create: vi.fn(),
findAllForBooking: vi.fn().mockResolvedValue([]),
findRescheduledLogsOfBooking: vi.fn(),
};
mockMembershipRepository = {
hasMembership: vi.fn(),
};
mockPermissionCheckService = {
checkPermission: vi.fn(),
};
mockAttendeeRepository = {
findById: vi.fn(),
};
mockLog = {
error: vi.fn(),
};
vi.mocked(BookingRepository).mockImplementation(() => mockBookingRepository as unknown as BookingRepository);
vi.mocked(UserRepository).mockImplementation(() => mockUserRepository as unknown as UserRepository);
vi.mocked(MembershipRepository).mockImplementation(() => mockMembershipRepository as unknown as MembershipRepository);
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as unknown as PermissionCheckService);
service = new BookingAuditViewerService({
bookingAuditRepository: mockBookingAuditRepository as unknown as IBookingAuditRepository,
userRepository: mockUserRepository as unknown as UserRepository,
bookingRepository: mockBookingRepository as unknown as BookingRepository,
membershipRepository: mockMembershipRepository as unknown as MembershipRepository,
attendeeRepository: mockAttendeeRepository as unknown as IAttendeeRepository,
log: mockLog as unknown as ISimpleLogger,
});
});
describe("getAuditLogsForBooking", () => {
describe("when user has permission to view audit logs", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should return enriched audit logs with actor information", async () => {
const mockLog = createMockAuditLog();
const mockUser = createMockUser();
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.bookingUid).toBe("booking-uid-123");
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0]).toMatchObject({
id: "audit-log-1",
bookingUid: "booking-uid-123",
action: "CREATED",
type: "RECORD_CREATED",
actor: expect.objectContaining({
type: "USER",
displayName: "John Doe",
displayEmail: "john@example.com",
displayAvatar: null,
}),
});
});
it("should format timestamps as ISO strings", async () => {
const mockLog = createMockAuditLog({
timestamp: new Date("2024-01-15T10:00:00Z"),
createdAt: new Date("2024-01-15T10:00:00Z"),
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs[0].timestamp).toBe("2024-01-15T10:00:00.000Z");
expect(result.auditLogs[0].createdAt).toBe("2024-01-15T10:00:00.000Z");
});
it("should include action display title with translation key", async () => {
const mockLog = createMockAuditLog({ action: "CREATED" });
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs[0].actionDisplayTitle).toEqual({
key: "booking_audit_action.created",
});
});
it("should handle multiple audit logs in correct order", async () => {
const logs = [
createMockAuditLog({
id: "log-1",
action: "CREATED",
timestamp: new Date("2024-01-15T10:00:00Z"),
data: { version: 1, fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" } }
}),
createMockAuditLog({
id: "log-2",
action: "ACCEPTED",
timestamp: new Date("2024-01-15T11:00:00Z"),
data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } }
}),
createMockAuditLog({
id: "log-3",
action: "CANCELLED",
timestamp: new Date("2024-01-15T12:00:00Z"),
data: {
version: 1,
fields: {
status: { old: "ACCEPTED", new: "CANCELLED" },
cancellationReason: { old: null, new: "User requested" },
cancelledBy: { old: null, new: "user-123" },
}
}
}),
];
mockBookingAuditRepository.findAllForBooking.mockResolvedValue(logs);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(3);
expect(result.auditLogs[0].id).toBe("log-1");
expect(result.auditLogs[1].id).toBe("log-2");
expect(result.auditLogs[2].id).toBe("log-3");
});
it("should return empty array when no audit logs exist", async () => {
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([]);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.bookingUid).toBe("booking-uid-123");
expect(result.auditLogs).toEqual([]);
});
});
describe("when enriching actor information", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should enrich USER actor with user details from repository", async () => {
const mockLog = createMockAuditLog({
actorType: "USER",
actorUserUuid: "user-uuid-456",
});
const mockUser = createMockUser({
name: "Jane Smith",
email: "jane@example.com",
avatarUrl: "https://example.com/avatar.jpg",
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "user-uuid-456" });
expect(result.auditLogs[0].actor).toMatchObject({
displayName: "Jane Smith",
displayEmail: "jane@example.com",
displayAvatar: "https://example.com/avatar.jpg",
});
});
it("should use email as display name when user name is null", async () => {
const mockLog = createMockAuditLog({
actorType: "USER",
actorName: null
});
const mockUser = createMockUser({ name: null, email: "user@example.com" });
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor.displayName).toBe("user@example.com");
});
it("should show 'Deleted User' when user not found in repository", async () => {
const mockLog = createMockAuditLog({ actorType: "USER" });
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(null);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor).toMatchObject({
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
});
});
it("should show 'Cal.com' for SYSTEM actor", async () => {
const mockLog = createMockAuditLog({
actorType: "SYSTEM",
actorUserUuid: null,
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor).toMatchObject({
type: "SYSTEM",
displayName: "Cal.com",
displayEmail: null,
displayAvatar: null,
});
expect(mockUserRepository.findByUuid).not.toHaveBeenCalled();
});
it("should show 'Guest' for GUEST actor without name", async () => {
const mockLog = createMockAuditLog({
actorType: "GUEST",
actorUserUuid: null,
actorName: null,
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor).toMatchObject({
type: "GUEST",
displayName: "Guest",
displayEmail: null,
displayAvatar: null,
});
});
it("should use provided name for GUEST actor", async () => {
const mockLog = createMockAuditLog({
actorType: "GUEST",
actorUserUuid: null,
actorName: "External Guest",
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor.displayName).toBe("External Guest");
});
it("should show 'Attendee' for ATTENDEE actor without name", async () => {
const mockLog = createMockAuditLog({
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: null,
});
// Update the mock to include attendeeId in the actor
const logWithAttendeeId = {
...mockLog,
actor: {
...mockLog.actor,
attendeeId: 999,
},
};
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
mockAttendeeRepository.findById.mockResolvedValue({
id: 999,
name: null,
email: "attendee@example.com",
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(999);
expect(result.auditLogs[0].actor).toMatchObject({
type: "ATTENDEE",
displayName: "attendee@example.com",
displayEmail: "attendee@example.com",
displayAvatar: null,
});
});
it("should use provided name for ATTENDEE actor", async () => {
const mockLog = createMockAuditLog({
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: "Meeting Participant",
});
// Update the mock to include attendeeId in the actor
const logWithAttendeeId = {
...mockLog,
actor: {
...mockLog.actor,
attendeeId: 888,
},
};
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
mockAttendeeRepository.findById.mockResolvedValue({
id: 888,
name: "Meeting Participant",
email: "participant@example.com",
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(888);
expect(result.auditLogs[0].actor.displayName).toBe("Meeting Participant");
expect(result.auditLogs[0].actor.displayEmail).toBe("participant@example.com");
});
it("should show 'Deleted Attendee' when attendee not found in repository", async () => {
const mockLog = createMockAuditLog({
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: null,
});
// Update the mock to include attendeeId in the actor
const logWithAttendeeId = {
...mockLog,
actor: {
...mockLog.actor,
attendeeId: 777,
},
};
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
mockAttendeeRepository.findById.mockResolvedValue(null);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(777);
expect(result.auditLogs[0].actor).toMatchObject({
type: "ATTENDEE",
displayName: "Deleted Attendee",
displayEmail: null,
displayAvatar: null,
});
});
});
describe("when handling rescheduled bookings", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should include rescheduled from log when booking was created from reschedule", async () => {
const currentBookingLog = createMockAuditLog({
id: "current-log",
bookingUid: "new-booking-uid",
action: "CREATED",
});
const rescheduledLog = createMockAuditLog({
id: "rescheduled-log",
bookingUid: "old-booking-uid",
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
rescheduledToUid: { old: null, new: "new-booking-uid" },
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]);
mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid");
mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "new-booking-uid",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(2);
expect(result.auditLogs[0].id).toBe("rescheduled-log");
expect(result.auditLogs[0].bookingUid).toBe("new-booking-uid");
expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from");
expect(result.auditLogs[0].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid");
expect(result.auditLogs[1].id).toBe("current-log");
});
it("should not include rescheduled from log when booking was not rescheduled", async () => {
const mockLog = createMockAuditLog();
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockBookingRepository.getFromRescheduleUid.mockResolvedValue(null);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(mockBookingAuditRepository.findRescheduledLogsOfBooking).not.toHaveBeenCalled();
});
it("should continue without rescheduled from log when matching log not found", async () => {
const currentBookingLog = createMockAuditLog({
bookingUid: "new-booking-uid",
});
const rescheduledLog = createMockAuditLog({
id: "rescheduled-log",
bookingUid: "old-booking-uid",
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
rescheduledToUid: { old: null, new: "different-booking-uid" },
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]);
mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid");
mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "new-booking-uid",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].id).toBe("audit-log-1");
expect(mockLog.error).toHaveBeenCalledWith(
"No rescheduled log found for booking old-booking-uid -> new-booking-uid"
);
});
});
describe("when permission check fails", () => {
it("should throw error when user lacks permission", async () => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
mockMembershipRepository.hasMembership.mockResolvedValue(true);
await expect(
service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
})
).rejects.toThrow(BookingAuditPermissionError);
expect(mockBookingAuditRepository.findAllForBooking).not.toHaveBeenCalled();
});
it("should throw error when organization ID is null", async () => {
await expect(
service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: null,
})
).rejects.toThrow(BookingAuditPermissionError);
expect(mockBookingAuditRepository.findAllForBooking).not.toHaveBeenCalled();
});
it("should throw error when booking not found", async () => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(null);
await expect(
service.getAuditLogsForBooking({
bookingUid: "non-existent-booking",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
})
).rejects.toThrow(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED);
expect(mockBookingAuditRepository.findAllForBooking).not.toHaveBeenCalled();
});
});
describe("when handling different timezones", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should pass user timezone to action service for display formatting", async () => {
const mockLog = createMockAuditLog({
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
rescheduledToUid: { old: null, new: "new-booking-uid" },
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "America/Los_Angeles",
organizationId: 200,
});
expect(result.auditLogs[0].actionDisplayTitle).toHaveProperty("params");
expect(result.auditLogs[0].displayJson).toBeDefined();
});
});
describe("when handling seat audit actions", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should handle SEAT_BOOKED action with seat information", async () => {
const mockLog = createMockAuditLog({
id: "seat-booked-log",
action: "SEAT_BOOKED",
data: {
version: 1,
fields: {
seatReferenceUid: "seat-ref-123",
attendeeEmail: "attendee@example.com",
attendeeName: "Jane Attendee",
startTime: 1705315200000,
endTime: 1705318800000,
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].action).toBe("SEAT_BOOKED");
expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.seat_booked");
expect(result.auditLogs[0].displayJson).toBeDefined();
});
it("should handle SEAT_RESCHEDULED action with time changes", async () => {
const mockLog = createMockAuditLog({
id: "seat-rescheduled-log",
action: "SEAT_RESCHEDULED",
data: {
version: 1,
fields: {
seatReferenceUid: "seat-ref-123",
attendeeEmail: "attendee@example.com",
startTime: {
old: "2024-01-15T10:00:00Z",
new: "2024-01-16T10:00:00Z",
},
endTime: {
old: "2024-01-15T11:00:00Z",
new: "2024-01-16T11:00:00Z",
},
rescheduledToBookingUid: {
old: null,
new: "new-booking-uid",
},
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].action).toBe("SEAT_RESCHEDULED");
expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.seat_rescheduled");
expect(result.auditLogs[0].actionDisplayTitle.params).toBeDefined();
expect(result.auditLogs[0].actionDisplayTitle.components).toBeDefined();
expect(result.auditLogs[0].displayJson).toBeDefined();
});
it("should handle SEAT_RESCHEDULED action without moving to different booking", async () => {
const mockLog = createMockAuditLog({
id: "seat-rescheduled-same-booking",
action: "SEAT_RESCHEDULED",
data: {
version: 1,
fields: {
seatReferenceUid: "seat-ref-123",
attendeeEmail: "attendee@example.com",
startTime: {
old: "2024-01-15T10:00:00Z",
new: "2024-01-15T11:00:00Z",
},
endTime: {
old: "2024-01-15T11:00:00Z",
new: "2024-01-15T12:00:00Z",
},
rescheduledToBookingUid: {
old: null,
new: null,
},
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "user@example.com",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].action).toBe("SEAT_RESCHEDULED");
expect(result.auditLogs[0].actionDisplayTitle.components).toBeUndefined();
});
});
});
});
@@ -1,8 +1,7 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { formatInTimeZone } from "date-fns-tz";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import type { BookingAuditTaskConsumer } from "./BookingAuditTaskConsumer";
import type { BookingAuditViewerService } from "./BookingAuditViewerService";
@@ -10,278 +9,480 @@ import { makeUserActor } from "../../../bookings/lib/types/actor";
import { getBookingAuditTaskConsumer } from "../../di/BookingAuditTaskConsumer.container";
import { getBookingAuditViewerService } from "../../di/BookingAuditViewerService.container";
/**
* Integration tests for booking audit system
* Tests the complete write-read cycle using real database
*/
const generateUniqueId = () => {
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(7);
return `${timestamp}-${randomSuffix}`;
};
const createTestUser = async (overrides?: { email?: string; username?: string; name?: string }) => {
const uniqueId = generateUniqueId();
return prisma.user.create({
data: {
email: overrides?.email || `test-user-${uniqueId}@example.com`,
username: overrides?.username || `testuser-${uniqueId}`,
name: overrides?.name || "Test User",
},
});
};
const createTestOrganization = async (overrides?: { name?: string; slug?: string }) => {
const uniqueId = generateUniqueId();
return prisma.team.create({
data: {
name: overrides?.name || `Test Org ${uniqueId}`,
slug: overrides?.slug || `test-org-${uniqueId}`,
isOrganization: true,
},
});
};
const createTestMembership = async (
userId: number,
teamId: number,
overrides?: { role?: MembershipRole; accepted?: boolean }
) => {
return prisma.membership.create({
data: {
userId,
teamId,
role: overrides?.role || MembershipRole.ADMIN,
accepted: overrides?.accepted ?? true,
},
});
};
const createTestEventType = async (
userId: number,
overrides?: { title?: string; slug?: string; length?: number }
) => {
const uniqueId = generateUniqueId();
return prisma.eventType.create({
data: {
title: overrides?.title || "Test Event Type",
slug: overrides?.slug || `test-event-${uniqueId}`,
length: overrides?.length || 60,
userId,
},
});
};
const createTestBooking = async (
userId: number,
eventTypeId: number,
overrides?: {
uid?: string;
title?: string;
startTime?: Date;
endTime?: Date;
status?: BookingStatus;
attendees?: Array<{ email: string; name: string; timeZone: string }>;
}
) => {
const uniqueId = generateUniqueId();
const startTime = overrides?.startTime || new Date();
const endTime = overrides?.endTime || new Date(startTime.getTime() + 60 * 60 * 1000);
return prisma.booking.create({
data: {
uid: overrides?.uid || `test-booking-${uniqueId}`,
title: overrides?.title || "Test Booking",
startTime,
endTime,
userId,
eventTypeId,
status: overrides?.status || BookingStatus.ACCEPTED,
attendees: {
create: overrides?.attendees || [],
},
},
});
};
const enableFeatureForOrganization = async (organizationId: number, featureSlug: string) => {
await prisma.feature.upsert({
where: { slug: featureSlug },
create: {
slug: featureSlug,
enabled: true,
description: `Test feature: ${featureSlug}`,
},
update: {
enabled: true,
},
});
await prisma.teamFeatures.upsert({
where: {
teamId_featureId: {
teamId: organizationId,
featureId: featureSlug,
},
},
create: {
teamId: organizationId,
featureId: featureSlug,
assignedBy: "test-system",
},
update: {},
});
};
const cleanupTestData = async (testData: {
bookingUid?: string;
userUuids?: string[];
attendeeEmails?: string[];
eventTypeId?: number;
organizationId?: number;
userIds?: number[];
featureSlug?: string;
}) => {
if (testData.bookingUid) {
await prisma.bookingAudit.deleteMany({
where: { bookingUid: testData.bookingUid },
});
}
if (testData.userUuids?.length || testData.attendeeEmails?.length) {
await prisma.auditActor.deleteMany({
where: {
OR: [
...(testData.userUuids?.map((uuid) => ({ userUuid: uuid })) || []),
...(testData.attendeeEmails?.map((email) => ({ email })) || []),
],
},
});
}
if (testData.attendeeEmails?.length) {
await prisma.attendee.deleteMany({
where: { email: { in: testData.attendeeEmails } },
});
}
if (testData.bookingUid) {
await prisma.booking.deleteMany({
where: { uid: testData.bookingUid },
});
}
if (testData.eventTypeId) {
await prisma.eventType.deleteMany({
where: { id: testData.eventTypeId },
});
}
if (testData.organizationId) {
if (testData.featureSlug) {
await prisma.teamFeatures.deleteMany({
where: {
teamId: testData.organizationId,
featureId: testData.featureSlug,
},
});
}
await prisma.membership.deleteMany({
where: { teamId: testData.organizationId },
});
await prisma.team.deleteMany({
where: { id: testData.organizationId },
});
}
if (testData.userIds?.length) {
await prisma.user.deleteMany({
where: { id: { in: testData.userIds } },
});
}
};
describe("Booking Audit Integration", () => {
let bookingAuditTaskConsumer: BookingAuditTaskConsumer;
let bookingAuditViewerService: BookingAuditViewerService;
// Test data holders
let testUserId: number;
let testUserUuid: string;
let testUserEmail: string;
let testBookingUid: string;
let testEventTypeId: number;
let testAttendeeEmail: string;
let testAttendeeUserId: number;
let testData: {
owner: { id: number; uuid: string; email: string };
attendee: { id: number; email: string };
organization: { id: number };
eventType: { id: number };
booking: { uid: string; startTime: Date; endTime: Date; status: BookingStatus };
};
beforeEach(async () => {
// Initialize services from DI containers
bookingAuditTaskConsumer = getBookingAuditTaskConsumer();
bookingAuditViewerService = getBookingAuditViewerService();
// Create test user
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(7);
const testUser = await prisma.user.create({
data: {
email: `test-audit-user-${timestamp}-${randomSuffix}@example.com`,
username: `testaudituser-${timestamp}-${randomSuffix}`,
name: "Test Audit User",
},
});
testUserId = testUser.id;
testUserUuid = testUser.uuid;
testUserEmail = testUser.email;
const owner = await createTestUser({ name: "Test Audit User" });
const organization = await createTestOrganization();
await createTestMembership(owner.id, organization.id);
await enableFeatureForOrganization(organization.id, "booking-audit");
const eventType = await createTestEventType(owner.id);
const attendee = await createTestUser({ name: "Test Attendee" });
// Create test event type
const eventType = await prisma.eventType.create({
data: {
title: "Test Event Type",
slug: `test-event-${timestamp}-${randomSuffix}`,
length: 60,
userId: testUserId,
},
});
testEventTypeId = eventType.id;
// Create test attendee user for permission tests (needed before booking creation)
const attendeeUser = await prisma.user.create({
data: {
email: `test-attendee-${timestamp}-${randomSuffix}@example.com`,
username: `testattendee-${timestamp}-${randomSuffix}`,
name: "Test Attendee",
},
});
testAttendeeUserId = attendeeUser.id;
testAttendeeEmail = attendeeUser.email;
// Create test booking with attendee in single atomic operation
const startTime = new Date();
const endTime = new Date(startTime.getTime() + 60 * 60 * 1000);
testBookingUid = `test-booking-${timestamp}-${randomSuffix}`;
await prisma.booking.create({
data: {
uid: testBookingUid,
title: "Test Booking",
startTime,
endTime,
userId: testUserId,
eventTypeId: testEventTypeId,
status: BookingStatus.ACCEPTED,
attendees: {
create: [
{
email: testAttendeeEmail,
name: "Test Attendee",
timeZone: "UTC",
},
],
const booking = await createTestBooking(owner.id, eventType.id, {
attendees: [
{
email: attendee.email,
name: attendee.name || "Test Attendee",
timeZone: "UTC",
},
},
],
});
testData = {
owner: { id: owner.id, uuid: owner.uuid, email: owner.email },
attendee: { id: attendee.id, email: attendee.email },
organization: { id: organization.id },
eventType: { id: eventType.id },
booking: {
uid: booking.uid,
startTime: booking.startTime,
endTime: booking.endTime,
status: booking.status,
},
};
});
afterEach(async () => {
// Clean up test data in correct order to respect foreign key constraints
if (!testData) return;
// Delete audit logs first (references actor)
await prisma.bookingAudit.deleteMany({
where: { bookingUid: testBookingUid },
});
// Delete actors
await prisma.auditActor.deleteMany({
where: {
OR: [
{ userUuid: testUserUuid },
{ email: testAttendeeEmail },
],
},
});
// Delete attendees
await prisma.attendee.deleteMany({
where: { email: testAttendeeEmail },
});
// Delete booking
await prisma.booking.deleteMany({
where: { uid: testBookingUid },
});
// Delete event type
await prisma.eventType.deleteMany({
where: { id: testEventTypeId },
});
// Delete users
await prisma.user.deleteMany({
where: {
id: {
in: [testUserId, testAttendeeUserId],
},
},
await cleanupTestData({
bookingUid: testData.booking?.uid,
userUuids: testData.owner?.uuid ? [testData.owner.uuid] : [],
attendeeEmails: testData.attendee?.email ? [testData.attendee.email] : [],
eventTypeId: testData.eventType?.id,
organizationId: testData.organization?.id,
userIds: [testData.owner?.id, testData.attendee?.id].filter((id): id is number => id !== undefined),
featureSlug: "booking-audit",
});
});
describe("CREATED action end-to-end flow", () => {
it("should create audit record and retrieve it with correct formatting", async () => {
// Arrange: Get booking details
const booking = await prisma.booking.findUnique({
where: { uid: testBookingUid },
select: {
startTime: true,
endTime: true,
status: true,
},
});
describe("when single booking is created", () => {
it("should create audit record and retrieve it with correct data formatting", async () => {
const actor = makeUserActor(testData.owner.uuid);
expect(booking).toBeDefined();
// Create actor using helper
const actor = makeUserActor(testUserUuid);
// Act: Create audit record using TaskConsumer
await bookingAuditTaskConsumer.onBookingAction({
bookingUid: testBookingUid,
bookingUid: testData.booking.uid,
actor,
action: "CREATED",
source: "WEBAPP",
operationId: `op-${Date.now()}`,
data: {
startTime: booking!.startTime.getTime(),
endTime: booking!.endTime.getTime(),
status: booking!.status,
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
},
timestamp: Date.now(),
});
// Retrieve audit logs
const result = await bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
testUserId,
testUserEmail
);
const result = await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: testData.booking.uid,
userId: testData.owner.id,
userEmail: testData.owner.email,
userTimeZone: "UTC",
organizationId: testData.organization.id,
});
// Assert: Verify audit log exists and has correct data
expect(result.bookingUid).toBe(testBookingUid);
expect(result.bookingUid).toBe(testData.booking.uid);
expect(result.auditLogs).toHaveLength(1);
const auditLog = result.auditLogs[0];
expect(auditLog.bookingUid).toBe(testBookingUid);
expect(auditLog.bookingUid).toBe(testData.booking.uid);
expect(auditLog.action).toBe("CREATED");
expect(auditLog.type).toBe("RECORD_CREATED");
// Verify audit data matches (getDisplayJson returns fields only, not versioned wrapper)
// getDisplayJson formats dates using formatDateTimeInTimeZone (yyyy-MM-dd HH:mm:ss format)
const displayData = auditLog.data
const expectedStartTime = formatInTimeZone(booking!.startTime, "UTC", "yyyy-MM-dd HH:mm:ss");
const expectedEndTime = formatInTimeZone(booking!.endTime, "UTC", "yyyy-MM-dd HH:mm:ss");
expect(displayData?.startTime).toBe(expectedStartTime);
expect(displayData?.endTime).toBe(expectedEndTime);
expect(displayData?.status).toBe(booking!.status);
const displayData = auditLog.displayJson as Record<string, unknown>;
expect(displayData).toBeDefined();
expect(displayData.startTime).toBeDefined();
expect(typeof displayData.startTime).toBe("string");
expect(displayData.startTime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(displayData.endTime).toBeDefined();
expect(typeof displayData.endTime).toBe("string");
expect(displayData.endTime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(displayData.status).toBe(testData.booking.status);
});
it("should enrich actor information with user details", async () => {
// Arrange: Get booking details
const booking = await prisma.booking.findUnique({
where: { uid: testBookingUid },
select: { startTime: true, endTime: true, status: true },
});
it("should enrich actor information with user details from database", async () => {
const actor = makeUserActor(testData.owner.uuid);
// Create actor using helper
const actor = makeUserActor(testUserUuid);
// Create audit record using TaskConsumer
await bookingAuditTaskConsumer.onBookingAction({
bookingUid: testBookingUid,
bookingUid: testData.booking.uid,
actor,
action: "CREATED",
source: "WEBAPP",
operationId: `op-${Date.now()}`,
data: {
startTime: booking!.startTime.getTime(),
endTime: booking!.endTime.getTime(),
status: booking!.status,
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
},
timestamp: Date.now(),
});
// Act: Retrieve audit logs
const result = await bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
testUserId,
testUserEmail
);
const result = await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: testData.booking.uid,
userId: testData.owner.id,
userEmail: testData.owner.email,
userTimeZone: "UTC",
organizationId: testData.organization.id,
});
// Assert: Verify actor enrichment
const auditLog = result.auditLogs[0];
expect(auditLog.actor.displayName).toBe("Test Audit User");
expect(auditLog.actor.displayEmail).toBe(testUserEmail);
expect(auditLog.actor.userUuid).toBe(testUserUuid);
expect(auditLog.actor.displayEmail).toBe(testData.owner.email);
expect(auditLog.actor.userUuid).toBe(testData.owner.uuid);
});
it.skip("should enforce permission checks correctly", async () => {
// Arrange: Get booking details
const booking = await prisma.booking.findUnique({
where: { uid: testBookingUid },
select: { startTime: true, endTime: true, status: true },
});
it.skip("should deny access to unauthorized users viewing audit logs", async () => {
const actor = makeUserActor(testData.owner.uuid);
// Create actor using helper
const actor = makeUserActor(testUserUuid);
// Create audit record using TaskConsumer
await bookingAuditTaskConsumer.onBookingAction({
bookingUid: testBookingUid,
bookingUid: testData.booking.uid,
actor,
action: "CREATED",
source: "WEBAPP",
operationId: `op-${Date.now()}`,
data: {
startTime: booking!.startTime.getTime(),
endTime: booking!.endTime.getTime(),
status: booking!.status,
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
},
timestamp: Date.now(),
});
// Act & Assert: Booking owner can view
const ownerResult = await bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
testUserId,
testUserEmail
);
const ownerResult = await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: testData.booking.uid,
userId: testData.owner.id,
userEmail: testData.owner.email,
userTimeZone: "UTC",
organizationId: testData.organization.id,
});
expect(ownerResult.auditLogs).toHaveLength(1);
// Act & Assert: Attendee can view
const attendeeResult = await bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
testAttendeeUserId,
testAttendeeEmail
);
expect(attendeeResult.auditLogs).toHaveLength(1);
// Act & Assert: Unauthorized user cannot view
const unauthorizedUserId = 999999;
const unauthorizedEmail = "unauthorized@example.com";
await expect(
bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
unauthorizedUserId,
unauthorizedEmail
)
).rejects.toThrow("You do not have permission to view audit logs for this booking");
bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: testData.booking.uid,
userId: unauthorizedUserId,
userEmail: unauthorizedEmail,
userTimeZone: "UTC",
organizationId: testData.organization.id,
})
).rejects.toThrow();
});
});
describe("when multiple bookings are created in bulk", () => {
it("should create audit records for all bookings with same operation ID", async () => {
const booking2 = await createTestBooking(testData.owner.id, testData.eventType.id, {
attendees: [
{
email: testData.attendee.email,
name: "Test Attendee",
timeZone: "UTC",
},
],
});
const booking3 = await createTestBooking(testData.owner.id, testData.eventType.id, {
attendees: [
{
email: testData.attendee.email,
name: "Test Attendee",
timeZone: "UTC",
},
],
});
const actor = makeUserActor(testData.owner.uuid);
const operationId = `bulk-op-${Date.now()}`;
const timestamp = Date.now();
await bookingAuditTaskConsumer.processBulkAuditTask(
{
bookings: [
{
bookingUid: testData.booking.uid,
data: {
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
},
},
{
bookingUid: booking2.uid,
data: {
startTime: booking2.startTime.getTime(),
endTime: booking2.endTime.getTime(),
status: booking2.status,
},
},
{
bookingUid: booking3.uid,
data: {
startTime: booking3.startTime.getTime(),
endTime: booking3.endTime.getTime(),
status: booking3.status,
},
},
],
actor,
action: "CREATED",
source: "WEBAPP",
operationId,
timestamp,
organizationId: testData.organization.id,
},
"test-task-id"
);
const result1 = await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: testData.booking.uid,
userId: testData.owner.id,
userEmail: testData.owner.email,
userTimeZone: "UTC",
organizationId: testData.organization.id,
});
const result2 = await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: booking2.uid,
userId: testData.owner.id,
userEmail: testData.owner.email,
userTimeZone: "UTC",
organizationId: testData.organization.id,
});
const result3 = await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: booking3.uid,
userId: testData.owner.id,
userEmail: testData.owner.email,
userTimeZone: "UTC",
organizationId: testData.organization.id,
});
expect(result1.auditLogs).toHaveLength(1);
expect(result2.auditLogs).toHaveLength(1);
expect(result3.auditLogs).toHaveLength(1);
expect(result1.auditLogs[0].action).toBe("CREATED");
expect(result2.auditLogs[0].action).toBe("CREATED");
expect(result3.auditLogs[0].action).toBe("CREATED");
// Verify all bookings share the same operationId
expect(result1.auditLogs[0].operationId).toBe(operationId);
expect(result2.auditLogs[0].operationId).toBe(operationId);
expect(result3.auditLogs[0].operationId).toBe(operationId);
await cleanupTestData({
bookingUid: booking2.uid,
});
await cleanupTestData({
bookingUid: booking3.uid,
});
});
});
});
@@ -0,0 +1,5 @@
import { z } from "zod";
// Action sources for audit tracking
export const ActionSourceSchema = z.enum(["API_V1", "API_V2", "WEBAPP", "WEBHOOK", "UNKNOWN"]);
export type ActionSource = z.infer<typeof ActionSourceSchema>;
@@ -1,24 +1,13 @@
import { z } from "zod";
import { ActorSchema } from "@calcom/features/bookings/lib/types/actor";
import { CreatedAuditActionService } from "../actions/CreatedAuditActionService";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
import { CancelledAuditActionService } from "../actions/CancelledAuditActionService";
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService";
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService";
import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService";
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
import { PiiFreeActorSchema } from "../../../bookings/lib/types/actor";
import { ActionSourceSchema } from "./actionSource";
/**
* Supported booking audit actions
* Used for runtime validation of action field
*/
export const BookingAuditActionSchema = z.enum([
const BookingAuditActionSchema = z.enum([
"CREATED",
"RESCHEDULED",
"ACCEPTED",
@@ -31,47 +20,55 @@ export const BookingAuditActionSchema = z.enum([
"REASSIGNMENT",
"LOCATION_CHANGED",
"ATTENDEE_NO_SHOW_UPDATED",
"SEAT_BOOKED",
"SEAT_RESCHEDULED",
]);
export type BookingAuditAction = z.infer<typeof BookingAuditActionSchema>;
type AuditActionData<T extends { TYPE: string; latestFieldsSchema: z.ZodType }> = {
action: T["TYPE"];
data: z.infer<T["latestFieldsSchema"]>;
};
const actionAgnosticDataSchema = z.unknown();
/**
* Producer action data type - discriminated union for type-safe queueing
* Used by the legacy queueAudit method for backwards compatibility
*/
export type BookingAuditTaskProducerActionData =
| AuditActionData<typeof CreatedAuditActionService>
| AuditActionData<typeof RescheduledAuditActionService>
| AuditActionData<typeof AcceptedAuditActionService>
| AuditActionData<typeof CancelledAuditActionService>
| AuditActionData<typeof RescheduleRequestedAuditActionService>
| AuditActionData<typeof AttendeeAddedAuditActionService>
| AuditActionData<typeof HostNoShowUpdatedAuditActionService>
| AuditActionData<typeof RejectedAuditActionService>
| AuditActionData<typeof AttendeeRemovedAuditActionService>
| AuditActionData<typeof ReassignmentAuditActionService>
| AuditActionData<typeof LocationChangedAuditActionService>
| AuditActionData<typeof AttendeeNoShowUpdatedAuditActionService>;
/**
* Lean base schema for booking audit task payload
*
* Uses `data: z.unknown()` to avoid large discriminated union.
* The consumer parses with this first, then validates `data`
* with the action-specific schema based on the `action` field.
*/
export const BookingAuditTaskBaseSchema = z.object({
const bookingAuditPayloadSchema = z.object({
bookingUid: z.string(),
actor: ActorSchema,
data: actionAgnosticDataSchema,
});
export const SingleBookingAuditTaskConsumerSchema = z.object({
isBulk: z.literal(false),
...bookingAuditPayloadSchema.shape,
actor: PiiFreeActorSchema,
organizationId: z.number().nullable(),
timestamp: z.number(),
action: BookingAuditActionSchema,
data: z.unknown(),
source: ActionSourceSchema.default("UNKNOWN"),
operationId: z.string(),
});
export type BookingAuditTaskBasePayload = z.infer<typeof BookingAuditTaskBaseSchema>;
export type SingleBookingAuditTaskConsumerPayload = z.infer<typeof SingleBookingAuditTaskConsumerSchema>;
/**
* Bulk booking audit task payload schema
*
* Used for operations that affect multiple bookings in a single action.
* Contains an array of bookings, each with bookingUid and action-specific data.
* All bookings share the same actor, organizationId, timestamp, action, source, and operationId.
*/
export const BulkBookingAuditTaskConsumerSchema = z.object({
isBulk: z.literal(true),
bookings: z.array(bookingAuditPayloadSchema).min(1),
actor: PiiFreeActorSchema,
organizationId: z.number().nullable(),
timestamp: z.number(),
action: BookingAuditActionSchema,
source: ActionSourceSchema.default("UNKNOWN"),
operationId: z.string(),
});
export type BulkBookingAuditTaskConsumerPayload = z.infer<typeof BulkBookingAuditTaskConsumerSchema>;
export const BookingAuditTaskConsumerSchema = z.discriminatedUnion("isBulk", [
SingleBookingAuditTaskConsumerSchema,
BulkBookingAuditTaskConsumerSchema,
]);
export type BookingAuditTaskConsumerPayload = z.infer<typeof BookingAuditTaskConsumerSchema>;
@@ -0,0 +1,15 @@
import { AttendeeRepository } from "@calcom/features/bookings/repositories/AttendeeRepository";
import { type Container, createModule } from "@calcom/features/di/di";
import { DI_TOKENS } from "@calcom/features/di/tokens";
export const attendeeRepositoryModule = createModule();
const token = DI_TOKENS.ATTENDEE_REPOSITORY;
const moduleToken = DI_TOKENS.ATTENDEE_REPOSITORY_MODULE;
attendeeRepositoryModule.bind(token).toClass(AttendeeRepository, [DI_TOKENS.PRISMA_CLIENT]);
export const moduleLoader = {
token,
loadModule: function (container: Container) {
container.load(moduleToken, attendeeRepositoryModule);
},
};
+2
View File
@@ -11,4 +11,6 @@ export const BOOKING_DI_TOKENS = {
BOOKING_EMAIL_SMS_HANDLER_MODULE: Symbol("BookingEmailSmsHandlerModule"),
BOOKING_EVENT_HANDLER_SERVICE: Symbol("BookingEventHandlerService"),
BOOKING_EVENT_HANDLER_SERVICE_MODULE: Symbol("BookingEventHandlerServiceModule"),
ATTENDEE_REPOSITORY: Symbol("AttendeeRepository"),
ATTENDEE_REPOSITORY_MODULE: Symbol("AttendeeRepositoryModule"),
};
@@ -1,10 +1,23 @@
import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface";
import type { AcceptedAuditData } from "@calcom/features/booking-audit/lib/actions/AcceptedAuditActionService";
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 { SeatBookedAuditData } from "@calcom/features/booking-audit/lib/actions/SeatBookedAuditActionService";
import type { SeatRescheduledAuditData } from "@calcom/features/booking-audit/lib/actions/SeatRescheduledAuditActionService";
import type { CreatedAuditData } from "@calcom/features/booking-audit/lib/actions/CreatedAuditActionService";
import type { RescheduledAuditData } from "@calcom/features/booking-audit/lib/actions/RescheduledAuditActionService";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { Actor } from "../types/actor";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types";
interface BookingEventHandlerDeps {
@@ -13,43 +26,87 @@ interface BookingEventHandlerDeps {
bookingAuditProducerService: BookingAuditProducerService;
}
interface OnBookingCreatedParams {
payload: BookingCreatedPayload;
actor: Actor;
auditData: CreatedAuditData;
source: ActionSource;
operationId?: string | null;
}
interface OnBookingRescheduledParams {
payload: BookingRescheduledPayload;
actor: Actor;
auditData: RescheduledAuditData;
source: ActionSource;
operationId?: string | null;
}
interface BaseBookingEventParams<TAuditData> {
bookingUid: string;
actor: Actor;
organizationId: number | null;
auditData: TAuditData;
source: ActionSource;
operationId?: string | null;
}
type OnBookingAcceptedParams = BaseBookingEventParams<AcceptedAuditData>;
type OnBookingCancelledParams = BaseBookingEventParams<CancelledAuditData>;
type OnRescheduleRequestedParams = BaseBookingEventParams<RescheduleRequestedAuditData>;
type OnAttendeeAddedParams = BaseBookingEventParams<AttendeeAddedAuditData>;
type OnHostNoShowUpdatedParams = BaseBookingEventParams<HostNoShowUpdatedAuditData>;
type OnBookingRejectedParams = BaseBookingEventParams<RejectedAuditData>;
type OnAttendeeRemovedParams = BaseBookingEventParams<AttendeeRemovedAuditData>;
type OnReassignmentParams = BaseBookingEventParams<ReassignmentAuditData>;
type OnLocationChangedParams = BaseBookingEventParams<LocationChangedAuditData>;
type OnAttendeeNoShowUpdatedParams = BaseBookingEventParams<AttendeeNoShowUpdatedAuditData>;
type OnSeatBookedParams = BaseBookingEventParams<SeatBookedAuditData>;
type OnSeatRescheduledParams = BaseBookingEventParams<SeatRescheduledAuditData>;
export class BookingEventHandlerService {
private readonly log: BookingEventHandlerDeps["log"];
private readonly bookingAuditProducerService: BookingEventHandlerDeps["bookingAuditProducerService"];
constructor(private readonly deps: BookingEventHandlerDeps) {
this.log = deps.log;
this.bookingAuditProducerService = deps.bookingAuditProducerService;
}
async onBookingCreated(payload: BookingCreatedPayload, actor: Actor) {
async onBookingCreated(params: OnBookingCreatedParams) {
const { payload, actor, auditData, source, operationId } = params;
this.log.debug("onBookingCreated", safeStringify(payload));
if (payload.config.isDryRun) {
return;
}
await this.onBookingCreatedOrRescheduled(payload);
if (IS_PRODUCTION) {
// Skip queueing audit for production environments till we are absolutely sure that the payload schema is correct for CREATED action
// We might get more clarity as we implement more actions and test them
return;
}
try {
await this.deps.bookingAuditProducerService.queueAudit(payload.booking.uid, actor, payload.organizationId, {
action: "CREATED",
data: {
startTime: payload.booking.startTime.getTime(),
endTime: payload.booking.endTime.getTime(),
status: payload.booking.status,
},
});
} catch (error) {
this.log.error("Error while queueing booking audit", safeStringify(error));
}
await this.deps.bookingAuditProducerService.queueCreatedAudit({
bookingUid: payload.booking.uid,
actor,
organizationId: payload.organizationId,
source,
operationId,
data: auditData,
});
}
async onBookingRescheduled(payload: BookingRescheduledPayload) {
async onBookingRescheduled(params: OnBookingRescheduledParams) {
const { payload, actor, auditData, source, operationId } = params;
this.log.debug("onBookingRescheduled", safeStringify(payload));
if (payload.config.isDryRun) {
return;
}
await this.onBookingCreatedOrRescheduled(payload);
await this.bookingAuditProducerService.queueRescheduledAudit({
// In case of rescheduled booking, we send old booking uid because the action took place on that booking only
bookingUid: payload.oldBooking.uid,
actor,
organizationId: payload.organizationId,
source,
operationId,
data: auditData,
});
}
/**
@@ -80,4 +137,202 @@ export class BookingEventHandlerService {
this.log.error("Error while updating hashed link", safeStringify(error));
}
}
async onBookingAccepted(params: OnBookingAcceptedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueAcceptedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onBookingCancelled(params: OnBookingCancelledParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueCancelledAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onRescheduleRequested(params: OnRescheduleRequestedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueRescheduleRequestedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onAttendeeAdded(params: OnAttendeeAddedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueAttendeeAddedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onHostNoShowUpdated(params: OnHostNoShowUpdatedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueHostNoShowUpdatedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onBookingRejected(params: OnBookingRejectedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueRejectedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onAttendeeRemoved(params: OnAttendeeRemovedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueAttendeeRemovedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onReassignment(params: OnReassignmentParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueReassignmentAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onLocationChanged(params: OnLocationChangedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueLocationChangedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onAttendeeNoShowUpdated(params: OnAttendeeNoShowUpdatedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueAttendeeNoShowUpdatedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onSeatBooked(params: OnSeatBookedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueSeatBookedAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
async onSeatRescheduled(params: OnSeatRescheduledParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
await this.bookingAuditProducerService.queueSeatRescheduledAudit({
bookingUid,
actor,
organizationId,
source,
operationId,
data: auditData,
});
}
/**
* Handles bulk booking acceptance for recurring bookings
* Creates a single task that will be processed to create multiple audit logs atomically
*/
async onBulkBookingsAccepted(params: {
bookings: Array<{
bookingUid: string;
auditData: AcceptedAuditData;
}>;
actor: Actor;
organizationId: number | null;
operationId?: string | null;
source: ActionSource;
}) {
const { bookings, actor, organizationId, operationId, source } = params;
await this.bookingAuditProducerService.queueBulkAcceptedAudit({
bookings: bookings.map((booking) => ({
bookingUid: booking.bookingUid,
data: booking.auditData,
})),
actor,
organizationId,
source,
operationId,
});
}
/**
* Handles bulk booking cancellation for recurring bookings
* Creates a single task that will be processed to create multiple audit logs atomically
*/
async onBulkBookingsCancelled(params: {
bookings: Array<{
bookingUid: string;
auditData: CancelledAuditData;
}>;
actor: Actor;
organizationId: number | null;
operationId?: string | null;
source: ActionSource;
}) {
const { bookings, actor, organizationId, operationId, source } = params;
await this.bookingAuditProducerService.queueBulkCancelledAudit({
bookings: bookings.map((booking) => ({
bookingUid: booking.bookingUid,
data: booking.auditData,
})),
actor,
organizationId,
source,
operationId,
});
}
}
+4 -2
View File
@@ -18,9 +18,11 @@ export interface BookingCreatedPayload {
};
organizationId: number | null;
}
export interface BookingRescheduledPayload extends BookingCreatedPayload {
oldBooking?: {
oldBooking: {
uid: string;
startTime: Date;
endTime: Date;
};
}
}
@@ -605,9 +605,9 @@ async function handler(
userId: userId ?? null,
eventType: eventType
? {
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
}
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
}
: null,
});
@@ -1376,9 +1376,9 @@ async function handler(
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
? {
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
: getLocationValueForDB(locationBodyString, eventType.locations);
tracingLogger.info("locationBodyString", locationBodyString);
@@ -1424,8 +1424,8 @@ async function handler(
const destinationCalendar = eventType.destinationCalendar
? [eventType.destinationCalendar]
: organizerUser.destinationCalendar
? [organizerUser.destinationCalendar]
: null;
? [organizerUser.destinationCalendar]
: null;
let organizerEmail = organizerUser.email || "Email-less";
if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) {
@@ -2047,14 +2047,14 @@ async function handler(
}
const updateManager = !skipCalendarSyncTaskCreation
? await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
: placeholderCreatedEvent;
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
@@ -2355,8 +2355,8 @@ async function handler(
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
: undefined;
const bookingCreatedPayload = buildBookingCreatedPayload({
@@ -2368,25 +2368,53 @@ async function handler(
organizationId: eventOrganizationId,
});
const bookingRescheduledPayload: BookingRescheduledPayload = {
...bookingCreatedPayload,
oldBooking: originalRescheduledBooking
? {
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: undefined,
};
const bookingEventHandler = deps.bookingEventHandler;
// TODO: Identify action source correctly
const actionSource = 'WEBAPP';
// TODO: We need to check session in booking flow and accordingly create USER actor if applicable.
const auditActor = makeGuestActor({ email: bookerEmail, name: fullName });
// TODO: Incrementally move all stuff that happens after a booking is created to these handlers
if (originalRescheduledBooking) {
await bookingEventHandler.onBookingRescheduled(bookingRescheduledPayload);
const bookingRescheduledPayload: BookingRescheduledPayload = {
...bookingCreatedPayload,
oldBooking: {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
},
};
await bookingEventHandler.onBookingRescheduled({
payload: bookingRescheduledPayload,
actor: auditActor,
auditData: {
startTime: {
old: bookingRescheduledPayload.oldBooking?.startTime.toISOString() ?? null,
new: bookingRescheduledPayload.booking.startTime.toISOString(),
},
endTime: {
old: bookingRescheduledPayload.oldBooking?.endTime.toISOString() ?? null,
new: bookingRescheduledPayload.booking.endTime.toISOString(),
},
rescheduledToUid: {
old: null,
new: bookingRescheduledPayload.booking.uid,
},
},
source: actionSource,
operationId: null,
});
} else {
// TODO: We need to check session in booking flow and accordingly create USER actor if applicable.
const auditActor = makeGuestActor({ email: bookerEmail, name: fullName });
await bookingEventHandler.onBookingCreated(bookingCreatedPayload, auditActor);
await bookingEventHandler.onBookingCreated({
payload: bookingCreatedPayload,
actor: auditActor,
auditData: {
startTime: bookingCreatedPayload.booking.startTime.getTime(),
endTime: bookingCreatedPayload.booking.endTime.getTime(),
status: bookingCreatedPayload.booking.status,
},
source: actionSource,
operationId: null,
});
}
const webhookData: EventPayloadType = {
@@ -2451,9 +2479,9 @@ async function handler(
...eventType,
metadata: eventType.metadata
? {
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
: {},
},
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
@@ -2791,7 +2819,7 @@ async function handler(
* We are open to renaming it to something more descriptive.
*/
export class RegularBookingService implements IBookingService {
constructor(private readonly deps: IBookingServiceDependencies) {}
constructor(private readonly deps: IBookingServiceDependencies) { }
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
+66 -24
View File
@@ -5,13 +5,6 @@ const UserActorSchema = z.object({
userUuid: z.string(),
});
const GuestActorSchema = z.object({
identifiedBy: z.literal("guest"),
email: z.string().optional(),
name: z.string().optional(),
phone: z.string().optional(),
});
const AttendeeActorSchema = z.object({
identifiedBy: z.literal("attendee"),
attendeeId: z.number(),
@@ -22,19 +15,47 @@ const ActorByIdSchema = z.object({
id: z.string(),
});
const GuestActorSchema = z.object({
identifiedBy: z.literal("guest"),
email: z.string(),
name: z.string().nullable(),
});
const AppActorByCredentialIdSchema = z.object({
identifiedBy: z.literal("app"),
credentialId: z.number(),
});
const AppActorBySlugSchema = z.object({
identifiedBy: z.literal("appSlug"),
appSlug: z.string(),
name: z.string(),
});
export const ActorSchema = z.discriminatedUnion("identifiedBy", [
ActorByIdSchema,
UserActorSchema,
AttendeeActorSchema,
GuestActorSchema,
AppActorByCredentialIdSchema,
AppActorBySlugSchema,
]);
export const PiiFreeActorSchema = z.discriminatedUnion("identifiedBy", [
ActorByIdSchema,
UserActorSchema,
AttendeeActorSchema,
]);
export type Actor = z.infer<typeof ActorSchema>;
export type PiiFreeActor = z.infer<typeof PiiFreeActorSchema>;
type UserActor = z.infer<typeof UserActorSchema>;
type GuestActor = z.infer<typeof GuestActorSchema>;
type AttendeeActor = z.infer<typeof AttendeeActorSchema>;
type ActorById = z.infer<typeof ActorByIdSchema>;
type AppActorByCredentialId = z.infer<typeof AppActorByCredentialIdSchema>;
type AppActorBySlug = z.infer<typeof AppActorBySlugSchema>;
/**
* Creates an Actor representing a User by UUID
*/
@@ -45,6 +66,14 @@ export function makeUserActor(userUuid: string): UserActor {
};
}
export function makeGuestActor({ email, name }: { email: string, name: string | null }): GuestActor {
return {
identifiedBy: "guest",
email,
name: name ?? null,
};
}
/**
* Creates an Actor representing the System
* System actors must be referenced by ID (requires migration)
@@ -56,21 +85,6 @@ export function makeSystemActor(): ActorById {
};
}
/**
* Creates an Actor representing a Guest (non-registered attendee)
*/
export function makeGuestActor(params: {
email: string;
name?: string;
phone?: string;
}): GuestActor {
return {
identifiedBy: "guest",
email: params.email,
name: params.name,
phone: params.phone,
};
}
/**
* Creates an Actor by existing actor ID
@@ -92,5 +106,33 @@ export function makeAttendeeActor(attendeeId: number): AttendeeActor {
};
}
// System actor ID constant
/**
* Creates an Actor representing an App by credential ID (preferred)
* The credentialId uniquely identifies which app instance (e.g., which Stripe account)
* App name and slug are derived from the credential at display time
*/
export function makeAppActor(params: { credentialId: number }): AppActorByCredentialId {
return {
identifiedBy: "app",
credentialId: params.credentialId,
};
}
/**
* Creates an Actor representing an App by app slug (fallback)
* Used when credentialId is not available or for apps not yet migrated
* App actors use @app.internal email convention
*/
export function makeAppActorUsingSlug(params: { appSlug: string; name: string }): AppActorBySlug {
return {
identifiedBy: "appSlug",
appSlug: params.appSlug,
name: params.name,
};
}
export function buildActorEmail({ identifier, actorType }: { identifier: string, actorType: "system" | "guest" | "app" }): string {
return `${identifier}@${actorType}.internal`;
}
export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
@@ -0,0 +1,24 @@
import type { PrismaClient } from "@calcom/prisma";
import type { IAttendeeRepository } from "./IAttendeeRepository";
/**
* Prisma-based implementation of IAttendeeRepository
*
* This repository provides methods for looking up attendee information.
*/
export class AttendeeRepository implements IAttendeeRepository {
constructor(private prismaClient: PrismaClient) {}
async findById(id: number): Promise<{ name: string; email: string } | null> {
const attendee = await this.prismaClient.attendee.findUnique({
where: { id },
select: {
name: true,
email: true,
},
});
return attendee;
}
}
@@ -0,0 +1,9 @@
/**
* Attendee lookup interface
*
* This interface provides methods for looking up attendee information.
*/
export interface IAttendeeRepository {
findById(id: number): Promise<{ name: string; email: string } | null>;
}
@@ -0,0 +1,22 @@
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "@calcom/features/di/di";
export const credentialRepositoryModule = createModule();
const token = DI_TOKENS.CREDENTIAL_REPOSITORY;
const moduleToken = DI_TOKENS.CREDENTIAL_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: credentialRepositoryModule,
moduleToken,
token,
classs: CredentialRepository,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
@@ -25,7 +25,14 @@ type CredentialUpdateInput = {
};
export class CredentialRepository {
constructor(private primaClient: PrismaClient) {}
constructor(private primaClient: PrismaClient) { }
async findByCredentialId(id: number) {
return this.primaClient.credential.findUnique({
where: { id },
select: safeCredentialSelect,
});
}
async findByIdWithDelegationCredential(id: number) {
return this.primaClient.credential.findUnique({
@@ -35,6 +35,8 @@ export enum CustomAction {
ReadRecordings = "readRecordings",
Impersonate = "impersonate",
EditUsers = "editUsers",
ReadTeamAuditLogs = "readTeamAuditLogs",
ReadOrgAuditLogs = "readOrgAuditLogs",
}
export enum Scope {
@@ -482,6 +484,22 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
descriptionI18nKey: "pbac_desc_update_bookings",
dependsOn: ["booking.read"],
},
[CustomAction.ReadTeamAuditLogs]: {
description: "View team booking audit logs",
category: "booking",
i18nKey: "pbac_action_read_team_audit_logs",
descriptionI18nKey: "pbac_desc_view_team_booking_audit_logs",
scope: [Scope.Team],
dependsOn: ["booking.read"],
},
[CustomAction.ReadOrgAuditLogs]: {
description: "View organization booking audit logs",
category: "booking",
i18nKey: "pbac_action_read_org_audit_logs",
descriptionI18nKey: "pbac_desc_view_organization_booking_audit_logs",
scope: [Scope.Organization],
dependsOn: ["booking.read"],
},
},
[Resource.Insights]: {
_resource: {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { z } from "zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import type { BookingAuditTaskBasePayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask";
import type { BookingAuditTaskConsumerPayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask";
export type TaskerTypes = "internal" | "redis";
type TaskPayloads = {
sendWebhook: string;
@@ -37,7 +37,7 @@ type TaskPayloads = {
responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null;
routedEventTypeId?: number | null;
};
bookingAudit: BookingAuditTaskBasePayload;
bookingAudit: BookingAuditTaskConsumerPayload;
};
export type TaskTypes = keyof TaskPayloads;
export type TaskHandler = (payload: string, taskId?: string) => Promise<void>;
+15 -6
View File
@@ -1,6 +1,7 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getBookingAuditTaskConsumer } from "@calcom/features/booking-audit/di/BookingAuditTaskConsumer.container";
import { BookingAuditTaskConsumerSchema } from "@calcom/features/booking-audit/lib/types/bookingAuditTask";
const log = logger.getSubLogger({ prefix: ["[tasker] bookingAudit"] });
@@ -8,7 +9,7 @@ const log = logger.getSubLogger({ prefix: ["[tasker] bookingAudit"] });
* Booking Audit Task Handler
*
* Thin wrapper that parses JSON and delegates to BookingAuditTaskConsumer.
* Schema validation happens in processAuditTask for better separation of concerns.
* Routes to processAuditTask or processBulkAuditTask based on isBulk field.
*
*/
export async function bookingAudit(payload: string, taskId?: string): Promise<void> {
@@ -18,13 +19,22 @@ export async function bookingAudit(payload: string, taskId?: string): Promise<vo
}
const parsedPayload: unknown = JSON.parse(payload);
log.info(`Processing booking audit: taskId=${taskId}`);
const parseResult = BookingAuditTaskConsumerSchema.safeParse(parsedPayload);
if (!parseResult.success) {
const errorMsg = `Invalid booking audit payload: ${safeStringify(parseResult.error.errors)} | TaskId: ${taskId}`;
log.error(errorMsg);
throw new Error(errorMsg);
}
const validatedPayload = parseResult.data;
const bookingAuditTaskConsumer = getBookingAuditTaskConsumer();
await bookingAuditTaskConsumer.processAuditTask(parsedPayload, taskId);
log.info(`Successfully processed booking audit: taskId=${taskId}`);
if (validatedPayload.isBulk) {
await bookingAuditTaskConsumer.processBulkAuditTask(validatedPayload, taskId);
} else {
await bookingAuditTaskConsumer.processAuditTask(validatedPayload, taskId);
}
} catch (error) {
const errorMsg = `Error processing booking audit: ${safeStringify(error)} | TaskId: ${taskId}`;
log.error(errorMsg);
@@ -32,4 +42,3 @@ export async function bookingAudit(payload: string, taskId?: string): Promise<vo
throw error;
}
}
+1
View File
@@ -18,3 +18,4 @@ export { BookingEmailAndSmsTriggerDevTasker } from "@calcom/features/bookings/li
export { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker";
export { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler";
export { BookingAuditTaskerProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskerProducerService";
export { getAuditActorRepository } from "@calcom/features/booking-audit/di/AuditActorRepository.container";
@@ -0,0 +1,30 @@
/*
Warnings:
- Added the required column `operationId` to the `BookingAudit` table without a default value. This is not possible if the table is not empty.
- Added the required column `source` to the `BookingAudit` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "public"."BookingAuditSource" AS ENUM ('api_v1', 'api_v2', 'webapp', 'webhook', 'unknown');
-- AlterEnum
ALTER TYPE "public"."AuditActorType" ADD VALUE 'app';
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "public"."BookingAuditAction" ADD VALUE 'seat_booked';
ALTER TYPE "public"."BookingAuditAction" ADD VALUE 'seat_rescheduled';
-- AlterTable
ALTER TABLE "public"."BookingAudit" ADD COLUMN "operationId" TEXT NOT NULL,
ADD COLUMN "source" "public"."BookingAuditSource" NOT NULL;
-- CreateIndex
CREATE INDEX "BookingAudit_operationId_idx" ON "public"."BookingAudit"("operationId");
@@ -0,0 +1,14 @@
/*
Warnings:
- A unique constraint covering the columns `[credentialId]` on the table `AuditActor` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "public"."AuditActor" ADD COLUMN "credentialId" INTEGER;
-- CreateIndex
CREATE INDEX "AuditActor_credentialId_idx" ON "public"."AuditActor"("credentialId");
-- CreateIndex
CREATE UNIQUE INDEX "AuditActor_credentialId_key" ON "public"."AuditActor"("credentialId");
+44 -17
View File
@@ -1944,18 +1944,18 @@ model Avatar {
}
model OutOfOfficeEntry {
id Int @id @default(autoincrement())
uuid String @unique
start DateTime
end DateTime
notes String?
showNotePublicly Boolean @default(false)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
toUserId Int?
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
reasonId Int?
reason OutOfOfficeReason? @relation(fields: [reasonId], references: [id], onDelete: SetNull)
id Int @id @default(autoincrement())
uuid String @unique
start DateTime
end DateTime
notes String?
showNotePublicly Boolean @default(false)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
toUserId Int?
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
reasonId Int?
reason OutOfOfficeReason? @relation(fields: [reasonId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2719,6 +2719,18 @@ enum BookingAuditAction {
// Rescheduling
RESCHEDULE_REQUESTED @map("reschedule_requested")
// Seat management
SEAT_BOOKED @map("seat_booked")
SEAT_RESCHEDULED @map("seat_rescheduled")
}
enum BookingAuditSource {
API_V1 @map("api_v1")
API_V2 @map("api_v2")
WEBAPP @map("webapp")
WEBHOOK @map("webhook")
UNKNOWN @map("unknown")
}
enum AuditActorType {
@@ -2726,6 +2738,7 @@ enum AuditActorType {
GUEST @map("guest") // Non-registered user
ATTENDEE @map("attendee") // Has Attendee record with us
SYSTEM @map("system") // Automated actions
APP @map("app") // App-triggered actions (payment webhooks, integrations)
}
model AuditActor {
@@ -2734,13 +2747,19 @@ model AuditActor {
// References for different actor types (soft references, no FK constraints)
// These fields intentionally do NOT have foreign key constraints to preserve audit trail integrity:
// - When a User or Attendee is deleted, their AuditActor record persists with these IDs intact
// - When a User, Attendee, or Credential is deleted, their AuditActor record persists with these IDs intact
// - This maintains immutable audit history even after source records are removed
userUuid String? @db.Uuid // For USER type - references User.uuid without FK constraint
attendeeId Int? // For ATTENDEE type - references Attendee.id without FK constraint
// - For APP actors: when credential is deleted, name/email fields are populated to preserve app identity
userUuid String? @db.Uuid // For USER type - references User.uuid without FK constraint
attendeeId Int? // For ATTENDEE type - references Attendee.id without FK constraint
credentialId Int? // For APP type - references Credential.id without FK constraint
// Identity fields - only for GUEST/SYSTEM(System too might not have all) type. Attendee and User maintain their own identity fields.
// They could be set as anonymized for User/Attendee record as well when they are deleted to preserve the audit trail.
// Identity fields - for GUEST/SYSTEM/APP types when source record is deleted
// - GUEST: email/name stored directly
// - SYSTEM: email/name for system identifier
// - APP: email/name used when using appSlug mode (fallback), or preserved when credential deleted
// - USER/ATTENDEE: derive from their tables using userUuid/attendeeId
// These fields can be anonymized when User/Attendee records are deleted to preserve audit trail
email String?
phone String?
name String?
@@ -2751,11 +2770,13 @@ model AuditActor {
// TODO: Add pseudonymizedAt and related fields when we anonymize the data on deletion
@@unique([userUuid])
@@unique([attendeeId])
@@unique([credentialId]) // One actor per credential
@@unique([email]) // Prevent duplicate email actors
@@unique([phone]) // Prevent duplicate phone actors
@@index([email])
@@index([userUuid])
@@index([attendeeId])
@@index([credentialId])
}
model BookingAudit {
@@ -2785,11 +2806,17 @@ model BookingAudit {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
source BookingAuditSource
// Operation ID for correlating related audit logs across different audit types (BookingAudit, UserAudit, etc.) or for bulk operations
operationId String
data Json?
@@index([actorId])
@@index([bookingUid])
@@index([timestamp])
@@index([operationId])
}
enum PhoneNumberSubscriptionStatus {
@@ -1,6 +1,11 @@
import type { TFunction } from "i18next";
import type { PrismaClient } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
import { getBookingAuditViewerService } from "@calcom/features/booking-audit/di/BookingAuditViewerService.container";
import { BookingAuditErrorCode, BookingAuditPermissionError } from "@calcom/features/booking-audit/lib/service/BookingAuditAccessService";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { TrpcSessionUser } from "../../../types";
import type { TGetAuditLogsInputSchema } from "./getAuditLogs.schema";
@@ -13,18 +18,48 @@ type GetAuditLogsOptions = {
input: TGetAuditLogsInputSchema;
};
const getErrorMessage = (code: BookingAuditErrorCode, t: TFunction): string => {
switch (code) {
case BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED:
return t("audit_logs_organization_required");
case BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED:
return t("audit_logs_booking_not_found_or_permission_denied");
case BookingAuditErrorCode.BOOKING_HAS_NO_OWNER:
return t("audit_logs_booking_has_no_owner");
case BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION:
return t("audit_logs_owner_not_in_organization");
case BookingAuditErrorCode.PERMISSION_DENIED:
return t("audit_logs_permission_denied");
default:
return t("audit_logs_permission_check_error");
}
};
export const getAuditLogsHandler = async ({ ctx, input }: GetAuditLogsOptions) => {
const { user } = ctx;
const { bookingUid } = input;
const t = await getTranslation(user.locale ?? "en", "common");
const bookingAuditViewerService = getBookingAuditViewerService();
const result = await bookingAuditViewerService.getAuditLogsForBooking(
bookingUid,
user.id,
user.email
);
try {
const result = await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid,
userId: user.id,
userEmail: user.email,
userTimeZone: user.timeZone,
organizationId: user.organizationId,
});
return result;
return result;
} catch (error) {
if (error instanceof BookingAuditPermissionError) {
throw new TRPCError({
code: "FORBIDDEN",
message: getErrorMessage(error.code, t),
});
}
throw error;
}
};