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:
co-authored by
Devin AI
parent
9019be58ed
commit
f1011ddd08
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+5
-9
@@ -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,
|
||||
|
||||
+5
-9
@@ -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}`);
|
||||
|
||||
+144
-79
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
+267
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+892
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user