From 0fc26f782f859aa16c0a625fb80436b041e7b100 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 8 Dec 2025 17:38:58 +0530 Subject: [PATCH] feat: Add support to audit and view audit log with Booking CREATED action (#25468) ## What does this PR do? This PR introduces the booking audit infrastructure with a viewer service to fetch, enrich, and format audit logs. This is the first iteration that includes only the CREATED action with versioned schema Note: Additional audit actions, UI improvements to match with Figma will be taken care of in followup PRs, to ensure the PR size remains as small as possible(as it is large enough already) Demo - Loom - https://www.loom.com/share/22c2f38b052b480b8be3716e1608eaf6 ### Key Changes **New Booking Audit Package** (`packages/features/booking-audit/`): - `BookingAuditViewerService` - Fetches, enriches, and formats audit logs for display - `BookingAuditTaskConsumer` - Processes audit tasks asynchronously via Tasker - `CreatedAuditActionService` (v1) - Handles RECORD_CREATED action with versioned schema **Repository Layer**: - `BookingAuditRepository` - CRUD operations for audit records - `AuditActorRepository` - Actor management (users, guests, system) - `UserRepository.findByUuid()` - User lookup for actor enrichment **UI Components**: - New page at `/booking/logs/[bookinguid]` for viewing audit history - Filterable audit log list with search, type, and actor filters - Expandable log entries showing detailed change information **Infrastructure**: - `bookingAudit` Tasker task type for async processing - `booking-audit` feature flag (disabled by default) - tRPC endpoint `viewer.bookings.getAuditLogs` ### Updates since last revision - **Refactored `Task` class to `TaskRepository`**: Converted all static methods to instance methods following the repository pattern. A singleton `Task` is exported for backward compatibility, so existing call sites continue to work unchanged. ### Important Notes for Reviewers - **Permission check is TODO**: `checkPermissions()` in `BookingAuditViewerService` is not yet implemented - **Only CREATED action supported**: Other actions will throw an error - additional actions will be added iteratively in follow-up PRs - **Feature flag controlled**: System is behind `booking-audit` flag, disabled by default - **UI strings need i18n**: Some UI text in `booking-logs-view.tsx` may need translation ### Human Review Checklist - [ ] Verify permission enforcement strategy for audit log access - [ ] Review DI wiring in module files - [ ] Confirm error handling in `BookingAuditTaskConsumer` is appropriate for retry scenarios - [ ] Check if UI strings need to be added to translation files - [ ] Verify `TaskRepository` refactoring maintains backward compatibility (singleton export `Task` should work for all existing call sites) ## Mandatory Tasks (DO NOT REMOVE) - [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - internal infrastructure - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? Run the integration tests: ```bash TZ=UTC yarn test packages/features/booking-audit/lib/service/BookingAuditViewerService.integration-test.ts TZ=UTC yarn test packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.integration-test.ts ``` To test the UI: 1. Enable the `booking-audit` feature flag in the database 2. Create a booking (only CREATED action is supported in this PR) 3. Navigate to `/booking/logs/{bookingUid}` to view the audit trail ## Checklist - [x] I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - [x] My code follows the style guidelines of this project - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have checked if my changes generate no new warnings --- .../src/lib/modules/regular-booking.module.ts | 4 + .../booking-audit-producer.service.ts | 14 + .../services/booking-event-handler.service.ts | 8 +- .../api/v2/src/lib/services/tasker.service.ts | 18 + .../(main-nav)/booking/[uid]/logs/page.tsx | 48 +++ .../booking/logs/views/booking-logs-view.tsx | 340 ++++++++++++++++++ apps/web/public/static/locales/en/common.json | 12 + .../features/booking-audit/ARCHITECTURE.md | 66 ++-- .../di/ActorRepository.module.ts | 4 +- .../di/AuditActorRepository.module.ts | 24 ++ .../BookingAuditProducerService.container.ts | 15 + .../di/BookingAuditService.module.ts | 26 -- .../di/BookingAuditTaskConsumer.container.ts | 15 + .../di/BookingAuditTaskConsumer.module.ts | 29 ++ ...ookingAuditTaskerProducerService.module.ts | 24 ++ .../di/BookingAuditViewerService.container.ts | 15 + .../di/BookingAuditViewerService.module.ts | 29 ++ packages/features/booking-audit/di/tokens.ts | 12 +- .../lib/actions/AuditActionServiceHelper.ts | 65 ++++ .../lib/actions/CreatedAuditActionService.ts | 80 +++++ .../lib/actions/IAuditActionService.ts | 71 ++++ .../lib/repository/IAuditActorRepository.ts | 7 +- .../lib/repository/IBookingAuditRepository.ts | 28 +- .../repository/PrismaAuditActorRepository.ts | 82 +++++ .../PrismaBookingAuditRepository.ts | 32 +- .../BookingAuditProducerService.interface.ts | 34 ++ .../lib/service/BookingAuditService.ts | 67 ---- .../lib/service/BookingAuditTaskConsumer.ts | 300 ++++++++++++++++ .../BookingAuditTaskerProducerService.ts | 59 +++ .../lib/service/BookingAuditViewerService.ts | 179 +++++++++ .../service/booking-audit.integration-test.ts | 284 +++++++++++++++ .../lib/types/bookingAuditTask.ts | 29 ++ .../di/BookingEventHandlerService.module.ts | 5 +- .../di/RegularBookingService.module.ts | 1 - .../BookingEventHandlerService.ts | 29 +- .../bookings/lib/onBookingEvents/types.d.ts | 23 +- .../lib/service/RegularBookingService.ts | 111 ++++-- packages/features/bookings/lib/types/actor.ts | 88 +++-- .../di/shared/services/tasker.service.ts | 18 +- packages/features/flags/config.ts | 1 + packages/features/flags/hooks/index.ts | 1 + packages/features/tasker/repository.ts | 90 +++-- packages/features/tasker/task-processor.ts | 2 +- packages/features/tasker/tasker.ts | 5 +- .../features/tasker/tasks/bookingAudit.ts | 35 ++ packages/features/tasker/tasks/index.ts | 1 + .../users/repositories/UserRepository.ts | 137 +++---- packages/platform/libraries/bookings.ts | 1 + packages/platform/libraries/index.ts | 2 + .../migration.sql | 10 + .../routers/viewer/bookings/_router.tsx | 21 +- .../viewer/bookings/getAuditLogs.handler.ts | 30 ++ .../viewer/bookings/getAuditLogs.schema.ts | 8 + 53 files changed, 2302 insertions(+), 337 deletions(-) create mode 100644 apps/api/v2/src/lib/services/booking-audit-producer.service.ts create mode 100644 apps/api/v2/src/lib/services/tasker.service.ts create mode 100644 apps/web/app/(use-page-wrapper)/(main-nav)/booking/[uid]/logs/page.tsx create mode 100644 apps/web/modules/booking/logs/views/booking-logs-view.tsx create mode 100644 packages/features/booking-audit/di/AuditActorRepository.module.ts create mode 100644 packages/features/booking-audit/di/BookingAuditProducerService.container.ts delete mode 100644 packages/features/booking-audit/di/BookingAuditService.module.ts create mode 100644 packages/features/booking-audit/di/BookingAuditTaskConsumer.container.ts create mode 100644 packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts create mode 100644 packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts create mode 100644 packages/features/booking-audit/di/BookingAuditViewerService.container.ts create mode 100644 packages/features/booking-audit/di/BookingAuditViewerService.module.ts create mode 100644 packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts create mode 100644 packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/IAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts delete mode 100644 packages/features/booking-audit/lib/service/BookingAuditService.ts create mode 100644 packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts create mode 100644 packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts create mode 100644 packages/features/booking-audit/lib/service/BookingAuditViewerService.ts create mode 100644 packages/features/booking-audit/lib/service/booking-audit.integration-test.ts create mode 100644 packages/features/booking-audit/lib/types/bookingAuditTask.ts create mode 100644 packages/features/tasker/tasks/bookingAudit.ts create mode 100644 packages/prisma/migrations/20251201113559_add_booking_audit_feature_flag/migration.sql create mode 100644 packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts diff --git a/apps/api/v2/src/lib/modules/regular-booking.module.ts b/apps/api/v2/src/lib/modules/regular-booking.module.ts index d7f34a96eb..c52d00c72b 100644 --- a/apps/api/v2/src/lib/modules/regular-booking.module.ts +++ b/apps/api/v2/src/lib/modules/regular-booking.module.ts @@ -5,6 +5,7 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository"; import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; +import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service"; import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service"; import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service"; import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service"; @@ -16,6 +17,7 @@ import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booki import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service"; import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service"; import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service"; +import { TaskerService } from "@/lib/services/tasker.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { Module, Scope } from "@nestjs/common"; @@ -35,6 +37,7 @@ import { Module, Scope } from "@nestjs/common"; }, scope: Scope.TRANSIENT, }, + BookingAuditProducerService, BookingEventHandlerService, CheckBookingAndDurationLimitsService, CheckBookingLimitsService, @@ -45,6 +48,7 @@ import { Module, Scope } from "@nestjs/common"; BookingEmailAndSmsSyncTaskerService, BookingEmailAndSmsTriggerTaskerService, BookingEmailAndSmsTasker, + TaskerService, RegularBookingService, ], exports: [RegularBookingService], diff --git a/apps/api/v2/src/lib/services/booking-audit-producer.service.ts b/apps/api/v2/src/lib/services/booking-audit-producer.service.ts new file mode 100644 index 0000000000..f6b7f49b5f --- /dev/null +++ b/apps/api/v2/src/lib/services/booking-audit-producer.service.ts @@ -0,0 +1,14 @@ +import { TaskerService } from "@/lib/services/tasker.service"; +import { Injectable } from "@nestjs/common"; + +import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings"; + +@Injectable() +export class BookingAuditProducerService extends BookingAuditTaskerProducerService { + constructor(taskerService: TaskerService) { + super({ + tasker: taskerService.getTasker(), + }); + } +} + diff --git a/apps/api/v2/src/lib/services/booking-event-handler.service.ts b/apps/api/v2/src/lib/services/booking-event-handler.service.ts index 5a9f49dd94..dc968dd14f 100644 --- a/apps/api/v2/src/lib/services/booking-event-handler.service.ts +++ b/apps/api/v2/src/lib/services/booking-event-handler.service.ts @@ -4,14 +4,20 @@ import { BookingEventHandlerService as BaseBookingEventHandlerService } from "@c import { Logger } from "@/lib/logger.bridge"; +import { BookingAuditProducerService } from "./booking-audit-producer.service"; import { HashedLinkService } from "./hashed-link.service"; @Injectable() export class BookingEventHandlerService extends BaseBookingEventHandlerService { - constructor(hashedLinkService: HashedLinkService, bridgeLogger: Logger) { + constructor( + hashedLinkService: HashedLinkService, + bridgeLogger: Logger, + bookingAuditProducerService: BookingAuditProducerService + ) { super({ log: bridgeLogger, hashedLinkService, + bookingAuditProducerService, }); } } diff --git a/apps/api/v2/src/lib/services/tasker.service.ts b/apps/api/v2/src/lib/services/tasker.service.ts new file mode 100644 index 0000000000..a1520c5a8a --- /dev/null +++ b/apps/api/v2/src/lib/services/tasker.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from "@nestjs/common"; + +import { getTasker, type Tasker } from "@calcom/platform-libraries"; + +@Injectable() +export class TaskerService { + private readonly tasker: Tasker; + + constructor() { + this.tasker = getTasker(); + } + + getTasker(): Tasker { + return this.tasker; + } +} + + diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/booking/[uid]/logs/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/booking/[uid]/logs/page.tsx new file mode 100644 index 0000000000..5d5a072db4 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/booking/[uid]/logs/page.tsx @@ -0,0 +1,48 @@ +// Added as a separate route for now to ease the testing of the audit logs feature +// It partially matches the figma design - https://www.figma.com/design/wleA2SR6rn60EK7ORxAfMy/Cal.com-New-Features?node-id=5641-6732&p=f +// TOOD: Move it to the booking page side bar later +import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir"; +import type { PageProps } from "app/_types"; +import { _generateMetadata, getTranslate } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import BookingLogsView from "~/booking/logs/views/booking-logs-view"; + +export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) => + await _generateMetadata( + (t) => t("booking_history"), + (t) => t("booking_history_description"), + undefined, + undefined, + `/booking/${(await params).uid}/logs` + ); + +const Page = async ({ params }: PageProps) => { + const resolvedParams = await params; + const bookingUid = resolvedParams.uid; + + if (!bookingUid || typeof bookingUid !== "string") { + redirect("/bookings/upcoming"); + } + + const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user?.id) { + redirect("/auth/login"); + } + + return ( + + + + ); +}; + +export default Page; + diff --git a/apps/web/modules/booking/logs/views/booking-logs-view.tsx b/apps/web/modules/booking/logs/views/booking-logs-view.tsx new file mode 100644 index 0000000000..a954ed4631 --- /dev/null +++ b/apps/web/modules/booking/logs/views/booking-logs-view.tsx @@ -0,0 +1,340 @@ +/** + * TODO: Move it to features/booking-audit + */ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +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 { SkeletonText } from "@calcom/ui/components/skeleton"; +import { FilterSearchField, Select } from "@calcom/ui/components/form"; + +interface BookingLogsViewProps { + bookingUid: string; +} + +type AuditLog = { + id: string; + action: string; + type: string; + timestamp: string; + data: Record | null; + actor: { + type: string; + displayName: string | null; + }; +}; + +interface BookingLogsFiltersProps { + searchTerm: string; + onSearchChange: (value: string) => void; + typeFilter: string | null; + onTypeFilterChange: (value: string | null) => void; + actorFilter: string | null; + onActorFilterChange: (value: string | null) => void; + typeOptions: Array<{ label: string; value: string }>; + 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"; + } +}; + +function BookingLogsFilters({ + searchTerm, + onSearchChange, + typeFilter, + onTypeFilterChange, + actorFilter, + onActorFilterChange, + typeOptions, + actorOptions, +}: BookingLogsFiltersProps) { + const { t } = useLocale(); + + return ( +
+
+ onSearchChange(e.target.value)} + containerClassName="" + /> +
+ +
+ { + if (!option) return; + onActorFilterChange(option.value || null); + }} + options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]} + /> +
+
+ ); +} + +function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { + const { t } = useLocale(); + const [expandedLogIds, setExpandedLogIds] = useState>(new Set()); + const [showJsonMap, setShowJsonMap] = useState>({}); + + const toggleExpand = (logId: string) => { + setExpandedLogIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(logId)) { + newSet.delete(logId); + } else { + newSet.add(logId); + } + return newSet; + }); + }; + + const toggleJson = (logId: string) => { + setShowJsonMap((prev) => ({ + ...prev, + [logId]: !prev[logId], + })); + }; + + if (logs.length === 0) { + return ( +
+

{t("no_audit_logs_found")}

+
+ ); + } + + return ( +
+ {logs.map((log, index) => { + const isLast = index === logs.length - 1; + const isExpanded = expandedLogIds.has(log.id); + const actionDisplay = t(`audit_action.${log.action.toLowerCase()}`); + const showJson = showJsonMap[log.id] || false; + + return ( +
+
+
+
+ +
+
+ {!isLast && ( +
+ )} +
+ +
+
+
+
+

+ {actionDisplay} +

+
+ {log.actor.displayName} + • + {dayjs(log.timestamp).fromNow()} +
+
+ +
+
+ +
+
+ +
+
+ + {isExpanded && ( +
+
+
+ {t("type")} + {log.type} +
+
+ {t("actor")} + {log.actor.type} +
+
+ + {t("timestamp")} + + + {dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")} + +
+ + {log.data && Object.keys(log.data).length > 0 && ( +
+ + {showJson && ( +
+                                                        {JSON.stringify(log.data, null, 2)}
+                                                    
+ )} +
+ )} +
+
+ )} +
+
+ ); + })} +
+ ); +} + +export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) { + const router = useRouter(); + const [searchTerm, setSearchTerm] = useState(""); + const [typeFilter, setTypeFilter] = useState(null); + const [actorFilter, setActorFilter] = useState(null); + const { t } = useLocale(); + const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({ + bookingUid, + }); + + if (error) { + return ( +
+
+

{t("error_loading_booking_logs")}

+

{error.message}

+ +
+
+ ); + } + + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + const auditLogs = data?.auditLogs || []; + + // Apply filters + const filteredLogs = auditLogs.filter((log) => { + const matchesSearch = + !searchTerm || + log.action.toLowerCase().includes(searchTerm.toLowerCase()) || + log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesType = !typeFilter || log.action === typeFilter; + const matchesActor = !actorFilter || log.actor.type === actorFilter; + + return matchesSearch && matchesType && matchesActor; + }); + + const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action))); + const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))); + + const typeOptions = uniqueTypes.map((type) => ({ + label: t(`audit_action.${type.toLowerCase()}`), + value: type, + })); + + const actorOptions = uniqueActorTypes.map((actorType) => ({ + label: actorType, + value: actorType, + })); + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6c8f058146..3fb28a5840 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4093,5 +4093,17 @@ "booking_response": "Booking Response", "list_view": "List view", "calendar_view": "Calendar view", + "booking_history": "Booking History", + "booking_history_description": "View the history of actions performed on this booking", + "audit_action": { + "created": "Created" + }, + "error_loading_booking_logs": "Error loading booking logs", + "no_audit_logs_found": "No audit logs found", + "hide_details": "Hide details", + "show_details": "Show details", + "actor": "Actor", + "timestamp": "Timestamp", + "json": "JSON", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/booking-audit/ARCHITECTURE.md b/packages/features/booking-audit/ARCHITECTURE.md index 1751bc8196..602225a490 100644 --- a/packages/features/booking-audit/ARCHITECTURE.md +++ b/packages/features/booking-audit/ARCHITECTURE.md @@ -671,14 +671,19 @@ The audit system is designed to capture what actually happened, not to enforce b // ❌ BAD: Enforcing expected values if (booking.status === 'ACCEPTED' || booking.status === 'PENDING' || booking.status === 'AWAITING_HOST') { // Only audit if status is "expected" - await auditService.onBookingCreated(...); + await auditTaskConsumer.onBookingAction(...); } // ✅ GOOD: Recording actual state -await auditService.onBookingCreated(bookingId, userUuid, { - startTime: booking.startTime.toISOString(), - endTime: booking.endTime.toISOString(), - status: booking.status // Whatever it actually is +await bookingAuditProducerService.queueAudit({ + bookingUid, + actor, + action: "CREATED", + data: { + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + status: booking.status // Whatever it actually is + } }); ``` @@ -728,40 +733,47 @@ await prisma.auditActor.update({ `BookingEventHandlerService` is the primary entry point for tracking any booking changes. It acts as a coordinator that: 1. **Receives booking events** from various parts of the application (booking creation, status changes, updates, etc.) -2. **Relays to BookingAuditService** to create audit records +2. **Queues audit tasks** via BookingAuditProducerService for asynchronous processing 3. **Handles other side effects** such as webhooks, notifications, and workflow triggers -### BookingAuditService +### BookingAuditTaskConsumer -The audit system is accessed through `BookingAuditService`, which provides: +The audit system processes audit records through `BookingAuditTaskConsumer`, which provides: -### Convenience Methods +### Core Method -- `onBookingCreated()` - Track booking creation -- `onBookingAccepted()` - Track acceptance -- `onBookingRejected()` - Track rejection -- `onBookingCancelled()` - Track cancellation -- `onBookingRescheduled()` - Track reschedule -- `onAttendeeAdded()` - Track attendee addition -- `onAttendeeRemoved()` - Track attendee removal -- `onLocationChanged()` - Track location changes -- `onHostNoShowUpdated()` - Track host no-show -- `onAttendeeNoShowUpdated()` - Track attendee no-show -- `onReassignment()` - Track booking reassignment -- `onRescheduleRequested()` - Track reschedule requests +- `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 -- `getOrCreateUserActor()` - Ensures User actors exist before creating audits -- Automatic AuditActor creation/lookup for registered users +- `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 (Synchronous):** +**Current Flow (Asynchronous with Task Queue):** ``` -Booking Endpoint → BookingEventHandler.onBookingCreated() → await auditService, linkService, webhookService +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):** @@ -769,14 +781,14 @@ Booking Endpoint → BookingEventHandler.onBookingCreated() → await auditServi Booking Endpoint ↓ BookingEventHandler.onBookingCreated() [orchestrator] - ├─ tasks.trigger('bookingAudit', { bookingId, userUuid, data }) + ├─ 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 (with retries, monitoring) + ├─ 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) diff --git a/packages/features/booking-audit/di/ActorRepository.module.ts b/packages/features/booking-audit/di/ActorRepository.module.ts index 25748704b4..02cf670980 100644 --- a/packages/features/booking-audit/di/ActorRepository.module.ts +++ b/packages/features/booking-audit/di/ActorRepository.module.ts @@ -5,8 +5,8 @@ import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/ import { createModule } from "../../di/di"; export const actorRepositoryModule = createModule(); -const token = BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY; -const moduleToken = BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY_MODULE; +const token = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY_MODULE; const loadModule = bindModuleToClassOnToken({ module: actorRepositoryModule, moduleToken, diff --git a/packages/features/booking-audit/di/AuditActorRepository.module.ts b/packages/features/booking-audit/di/AuditActorRepository.module.ts new file mode 100644 index 0000000000..0cfb3b1ae1 --- /dev/null +++ b/packages/features/booking-audit/di/AuditActorRepository.module.ts @@ -0,0 +1,24 @@ +import { PrismaAuditActorRepository } from "@calcom/features/booking-audit/lib/repository/PrismaAuditActorRepository"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { bindModuleToClassOnToken } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { createModule } from "../../di/di"; + +export const auditActorRepositoryModule = createModule(); +const token = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: auditActorRepositoryModule, + moduleToken, + token, + classs: PrismaAuditActorRepository, + depsMap: { + prismaClient: prismaModuleLoader, + }, +}); + +export const moduleLoader = { + token, + loadModule, +}; + diff --git a/packages/features/booking-audit/di/BookingAuditProducerService.container.ts b/packages/features/booking-audit/di/BookingAuditProducerService.container.ts new file mode 100644 index 0000000000..5bd4660e1a --- /dev/null +++ b/packages/features/booking-audit/di/BookingAuditProducerService.container.ts @@ -0,0 +1,15 @@ +import { createContainer } from "@calcom/features/di/di"; +import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface"; + +import { + moduleLoader as bookingAuditTaskerProducerServiceModule, +} from "./BookingAuditTaskerProducerService.module"; + +const container = createContainer(); + +export function getBookingAuditProducerService() { + bookingAuditTaskerProducerServiceModule.loadModule(container); + + return container.get(bookingAuditTaskerProducerServiceModule.token); +} + diff --git a/packages/features/booking-audit/di/BookingAuditService.module.ts b/packages/features/booking-audit/di/BookingAuditService.module.ts deleted file mode 100644 index c1f57dc0c6..0000000000 --- a/packages/features/booking-audit/di/BookingAuditService.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService"; -import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; -import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module"; -import { moduleLoader as actorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/ActorRepository.module"; - -import { createModule, bindModuleToClassOnToken } from "../../di/di"; - -export const bookingAuditServiceModule = createModule(); -const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_SERVICE; -const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_SERVICE_MODULE; - -const loadModule = bindModuleToClassOnToken({ - module: bookingAuditServiceModule, - moduleToken, - token, - classs: BookingAuditService, - depsMap: { - bookingAuditRepository: bookingAuditRepositoryModuleLoader, - actorRepository: actorRepositoryModuleLoader, - }, -}); - -export const moduleLoader = { - token, - loadModule -}; diff --git a/packages/features/booking-audit/di/BookingAuditTaskConsumer.container.ts b/packages/features/booking-audit/di/BookingAuditTaskConsumer.container.ts new file mode 100644 index 0000000000..162f2e1f10 --- /dev/null +++ b/packages/features/booking-audit/di/BookingAuditTaskConsumer.container.ts @@ -0,0 +1,15 @@ +import { createContainer } from "@calcom/features/di/di"; +import type { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskConsumer"; + +import { + moduleLoader as bookingAuditTaskConsumerModule, +} from "./BookingAuditTaskConsumer.module"; + +const container = createContainer(); + +export function getBookingAuditTaskConsumer() { + bookingAuditTaskConsumerModule.loadModule(container); + + return container.get(bookingAuditTaskConsumerModule.token); +} + diff --git a/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts b/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts new file mode 100644 index 0000000000..2663ce8a96 --- /dev/null +++ b/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts @@ -0,0 +1,29 @@ +import { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskConsumer"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module"; +import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module"; +import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features"; + +import { createModule, bindModuleToClassOnToken } from "../../di/di"; + +export const bookingAuditTaskConsumerModule = createModule(); +const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_TASK_CONSUMER; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_TASK_CONSUMER_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: bookingAuditTaskConsumerModule, + moduleToken, + token, + classs: BookingAuditTaskConsumer, + depsMap: { + bookingAuditRepository: bookingAuditRepositoryModuleLoader, + auditActorRepository: auditActorRepositoryModuleLoader, + featuresRepository: featuresRepositoryModuleLoader, + }, +}); + +export const moduleLoader = { + token, + loadModule +}; + diff --git a/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts b/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts new file mode 100644 index 0000000000..372824a2b1 --- /dev/null +++ b/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts @@ -0,0 +1,24 @@ +import { BookingAuditTaskerProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskerProducerService"; +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 { createModule, bindModuleToClassOnToken } from "../../di/di"; + +export const bookingAuditProducerServiceModule = createModule(); +const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_PRODUCER_SERVICE; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_PRODUCER_SERVICE_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: bookingAuditProducerServiceModule, + moduleToken, + token, + classs: BookingAuditTaskerProducerService, + depsMap: { + tasker: taskerModuleLoader, + }, +}); + +export const moduleLoader = { + token, + loadModule +}; diff --git a/packages/features/booking-audit/di/BookingAuditViewerService.container.ts b/packages/features/booking-audit/di/BookingAuditViewerService.container.ts new file mode 100644 index 0000000000..add38c9a55 --- /dev/null +++ b/packages/features/booking-audit/di/BookingAuditViewerService.container.ts @@ -0,0 +1,15 @@ +import { createContainer } from "@calcom/features/di/di"; + +import { + type BookingAuditViewerService, + moduleLoader as bookingAuditViewerServiceModule, +} from "./BookingAuditViewerService.module"; + +const container = createContainer(); + +export function getBookingAuditViewerService() { + bookingAuditViewerServiceModule.loadModule(container); + + return container.get(bookingAuditViewerServiceModule.token); +} + diff --git a/packages/features/booking-audit/di/BookingAuditViewerService.module.ts b/packages/features/booking-audit/di/BookingAuditViewerService.module.ts new file mode 100644 index 0000000000..6a89ff7f8f --- /dev/null +++ b/packages/features/booking-audit/di/BookingAuditViewerService.module.ts @@ -0,0 +1,29 @@ +import { BookingAuditViewerService } from "@calcom/features/booking-audit/lib/service/BookingAuditViewerService"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module"; +import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User"; + +import { createModule, bindModuleToClassOnToken } from "../../di/di"; + +export const bookingAuditViewerServiceModule = createModule(); +const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE_MODULE; + +export { BookingAuditViewerService } + +const loadModule = bindModuleToClassOnToken({ + module: bookingAuditViewerServiceModule, + moduleToken, + token, + classs: BookingAuditViewerService, + depsMap: { + bookingAuditRepository: bookingAuditRepositoryModuleLoader, + userRepository: userRepositoryModuleLoader, + }, +}); + +export const moduleLoader = { + token, + loadModule +}; + diff --git a/packages/features/booking-audit/di/tokens.ts b/packages/features/booking-audit/di/tokens.ts index 4a93906a18..78472ceafb 100644 --- a/packages/features/booking-audit/di/tokens.ts +++ b/packages/features/booking-audit/di/tokens.ts @@ -1,8 +1,12 @@ export const BOOKING_AUDIT_DI_TOKENS = { - BOOKING_AUDIT_SERVICE: Symbol("BookingAuditService"), - BOOKING_AUDIT_SERVICE_MODULE: Symbol("BookingAuditServiceModule"), + BOOKING_AUDIT_VIEWER_SERVICE: Symbol("BookingAuditViewerService"), + BOOKING_AUDIT_VIEWER_SERVICE_MODULE: Symbol("BookingAuditViewerServiceModule"), + BOOKING_AUDIT_PRODUCER_SERVICE: Symbol("BookingAuditProducerService"), + BOOKING_AUDIT_PRODUCER_SERVICE_MODULE: Symbol("BookingAuditProducerServiceModule"), + BOOKING_AUDIT_TASK_CONSUMER: Symbol("BookingAuditTaskConsumer"), + BOOKING_AUDIT_TASK_CONSUMER_MODULE: Symbol("BookingAuditTaskConsumerModule"), BOOKING_AUDIT_REPOSITORY: Symbol("BookingAuditRepository"), BOOKING_AUDIT_REPOSITORY_MODULE: Symbol("BookingAuditRepositoryModule"), - ACTOR_REPOSITORY: Symbol("ActorRepository"), - ACTOR_REPOSITORY_MODULE: Symbol("ActorRepositoryModule"), + AUDIT_ACTOR_REPOSITORY: Symbol("AuditActorRepository"), + AUDIT_ACTOR_REPOSITORY_MODULE: Symbol("AuditActorRepositoryModule"), }; diff --git a/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts b/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts new file mode 100644 index 0000000000..e4e8dfec42 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +/** + * Audit Action Service Helper + * + * Provides reusable utility methods for audit action services via composition. + * + * We use composition instead of inheritance for Action services so that services can evolve to v2, v3 independently without polluting a shared base class + */ +export class AuditActionServiceHelper< + TLatestFieldsSchema extends z.ZodTypeAny, + TStoredDataSchema extends z.ZodTypeAny +> { + private readonly latestFieldsSchema: TLatestFieldsSchema; + private readonly latestVersion: number; + private readonly storedDataSchema: TStoredDataSchema; + + constructor({ + /** + * The schema to validate against latest version + */ + latestFieldsSchema, + latestVersion, + /** + * The schema to validate the stored data that could be of any version + */ + storedDataSchema, + }: { + latestFieldsSchema: TLatestFieldsSchema; + latestVersion: number; + storedDataSchema: TStoredDataSchema; + }) { + this.latestFieldsSchema = latestFieldsSchema; + this.latestVersion = latestVersion; + this.storedDataSchema = storedDataSchema; + } + + /** + * Parse input fields with the latest fields schema and wrap with version + */ + getVersionedData(fields: unknown): { version: number; fields: z.infer } { + const parsed = this.latestFieldsSchema.parse(fields); + return { + version: this.latestVersion, + fields: parsed, + }; + } + + /** + * Parse stored audit record (includes version wrapper) + * Accepts any version defined in allVersionsDataSchema (for backward compatibility) + */ + parseStored(data: unknown): z.infer { + return this.storedDataSchema.parse(data); + } + + /** + * Extract version from stored data + */ + getVersion(data: unknown): number { + const parsed = z.object({ version: z.number() }).parse(data); + return parsed.version; + } +} + diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts new file mode 100644 index 0000000000..caed606bc7 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService } from "./IAuditActionService"; + +/** + * Created Audit Action Service + * + * Note: CREATED 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({ + startTime: z.number(), + endTime: z.number(), + status: z.nativeEnum(BookingStatus), +}); + +export class CreatedAuditActionService implements IAuditActionService< + typeof fieldsSchemaV1, + typeof fieldsSchemaV1 +> { + readonly VERSION = 1; + public static readonly TYPE = "CREATED"; + 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 = CreatedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema, + storedDataSchema: CreatedAuditActionService.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 }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): CreatedAuditDisplayData { + return { + startTime: new Date(storedData.fields.startTime).toISOString(), + endTime: new Date(storedData.fields.endTime).toISOString(), + status: storedData.fields.status, + }; + } +} + +export type CreatedAuditData = z.infer; + +export type CreatedAuditDisplayData = { + startTime: string; + endTime: string; + status: BookingStatus; +}; + diff --git a/packages/features/booking-audit/lib/actions/IAuditActionService.ts b/packages/features/booking-audit/lib/actions/IAuditActionService.ts new file mode 100644 index 0000000000..aac3f555f4 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/IAuditActionService.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +/** + * Interface for Audit Action Services + * + * Defines the contract that all audit action services must implement. + * Uses composition with AuditActionServiceHelper to provide common functionality + * while maintaining type safety and flexibility for versioned schemas. + * + * @template 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) + */ +export interface IAuditActionService< + TLatestFieldsSchema extends z.ZodTypeAny, + TStoredFieldsSchema extends z.ZodTypeAny +> { + /** + * Current version number for this action type + */ + readonly VERSION: number; + + /** + * Parse given fields against latest schema and wrap with version + * @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 }; + + /** + * Parse stored audit record (includes version wrapper) + * Accepts data from ANY supported version (not just latest) for backward compatibility + * @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 }; + + /** + * Extract version number from stored data + * @param data - Stored data from database + * @returns Version number + */ + getVersion(data: unknown): number; + + /** + * Get flattened JSON data for display (fields only, no version wrapper) + * @param storedData - Parsed stored data { version, fields } + * @returns The fields object without version wrapper and we decide what fields to show to the client + */ + getDisplayJson(storedData: { version: number; fields: z.infer }): unknown; + + /** + * Migrate old version data to latest version + * + * Required method that validates and migrates data to latest schema version. + * For V1-only actions, simply validates and returns with isMigrated=false. + * For multi-version actions, checks version and transforms if needed. + * + * @param data - Data from task payload (any supported version) + * @returns Migration result with status and latest data + */ + migrateToLatest(data: unknown): { + /** + * True if migration was performed, false if already latest + */ + isMigrated: boolean; + /** + * Always set, either migrated or original + */ + latestData: z.infer; + }; +} \ No newline at end of file diff --git a/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts b/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts index 950ecabc07..6ff4eb4ec0 100644 --- a/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts +++ b/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts @@ -13,9 +13,8 @@ type AuditActor = { export interface IAuditActorRepository { findByUserUuid(userUuid: string): Promise; findSystemActorOrThrow(): Promise; - // TODO: To be implemented in followup PR - // upsertUserActor(userUuid: string): Promise; - // upsertGuestActor(email: string, name?: string, phone?: string): Promise; - // findByAttendeeId(attendeeId: number): Promise; + createIfNotExistsUserActor(params: { userUuid: string }): Promise; + createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null): Promise; + findByAttendeeId(attendeeId: number): Promise; } diff --git a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts index b9e5c0027c..e65fd66562 100644 --- a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts @@ -1,6 +1,15 @@ import type { JsonValue } from "@calcom/types/Json"; +import type { AuditActorType } from "./IAuditActorRepository"; export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED" + +/** + * Booking audit actions track changes to bookings throughout their lifecycle. + * + * Note: PENDING and AWAITING_HOST represent initial booking states, not transitions. + * 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 BookingAuditCreateInput = { bookingUid: string; @@ -23,10 +32,27 @@ type BookingAudit = { data: JsonValue; } +export type BookingAuditWithActor = BookingAudit & { + actor: { + id: string; + type: AuditActorType; + userUuid: string | null; + attendeeId: number | null; + name: string | null; + createdAt: Date; + }; +} + export interface IBookingAuditRepository { /** * Creates a new booking audit record */ create(bookingAudit: BookingAuditCreateInput): Promise; -} + /** + * Retrieves all audit logs for a specific booking + * @param bookingUid - The unique identifier of the booking + * @returns Array of audit logs with actor information, ordered by timestamp DESC + */ + findAllForBooking(bookingUid: string): Promise; +} diff --git a/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts b/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts index f2718b904d..a1a067c00b 100644 --- a/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts +++ b/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts @@ -25,5 +25,87 @@ export class PrismaAuditActorRepository implements IAuditActorRepository { return actor; } + + async createIfNotExistsUserActor(params: { userUuid: string }) { + return this.deps.prismaClient.auditActor.upsert({ + where: { userUuid: params.userUuid }, + create: { + type: "USER", + userUuid: params.userUuid, + }, + update: {}, + }); + } + + async createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null) { + const normalizedEmail = email && email.trim() !== "" ? email : null; + const normalizedName = name && name.trim() !== "" ? name : null; + const normalizedPhone = phone && phone.trim() !== "" ? phone : null; + + // If all fields are null, we can't use upsert (no unique constraint), so just create a new record + if (!normalizedEmail && !normalizedPhone) { + return this.deps.prismaClient.auditActor.create({ + data: { + type: "GUEST", + email: null, + name: normalizedName, + phone: null, + }, + }); + } + + // First try to find by email if email exists + if (normalizedEmail) { + const existingByEmail = await this.deps.prismaClient.auditActor.findUnique({ + where: { email: normalizedEmail }, + }); + + if (existingByEmail) { + // Update existing record found by email + return this.deps.prismaClient.auditActor.update({ + where: { email: normalizedEmail }, + data: { + name: normalizedName ?? undefined, + phone: normalizedPhone ?? undefined, + }, + }); + } + } + + // If not found by email and phone exists, try to find by phone + if (normalizedPhone) { + const existingByPhone = await this.deps.prismaClient.auditActor.findUnique({ + where: { phone: normalizedPhone }, + }); + + if (existingByPhone) { + // Update existing record found by phone + return this.deps.prismaClient.auditActor.update({ + where: { phone: normalizedPhone }, + data: { + email: normalizedEmail ?? undefined, + name: normalizedName ?? undefined, + }, + }); + } + } + + // Not found by either email or phone, create new record + return this.deps.prismaClient.auditActor.create({ + data: { + type: "GUEST", + email: normalizedEmail, + name: normalizedName, + phone: normalizedPhone, + }, + }); + } + + async findByAttendeeId(attendeeId: number) { + return this.deps.prismaClient.auditActor.findUnique({ + where: { attendeeId }, + }); + } + } diff --git a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts index e1b7f7bad9..41f604a198 100644 --- a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts @@ -1,10 +1,24 @@ import type { PrismaClient } from "@calcom/prisma"; -import type { IBookingAuditRepository, BookingAuditCreateInput } from "./IBookingAuditRepository"; +import type { IBookingAuditRepository, BookingAuditCreateInput, BookingAuditWithActor } from "./IBookingAuditRepository"; type Dependencies = { prismaClient: PrismaClient; } + +/** + * Safe actor fields to expose in audit logs + * Excludes PII fields like email and phone that aren't needed for display + */ +const safeActorSelect = { + id: true, + type: true, + userUuid: true, + attendeeId: true, + name: true, + createdAt: true, +} as const; + export class PrismaBookingAuditRepository implements IBookingAuditRepository { constructor(private readonly deps: Dependencies) { } @@ -20,5 +34,21 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository { }, }); } + + async findAllForBooking(bookingUid: string): Promise { + return this.deps.prismaClient.bookingAudit.findMany({ + where: { + bookingUid, + }, + include: { + actor: { + select: safeActorSelect, + }, + }, + orderBy: { + timestamp: "desc", + }, + }); + } } diff --git a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts new file mode 100644 index 0000000000..5568eeb342 --- /dev/null +++ b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts @@ -0,0 +1,34 @@ +import type { Actor } from "../../../bookings/lib/types/actor"; +import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask"; + +/** + * BookingAuditProducerService Interface + * + * Producer abstraction for queueing booking audit tasks. + * Allows for multiple implementations (e.g., Tasker, Trigger.dev). + * + * Implementations: + * - BookingAuditTaskerProducerService: Uses Tasker for local/background jobs + * - BookingAuditTriggerProducerService: (Future) Uses Trigger.dev for cloud jobs + */ +export interface BookingAuditProducerService { + /** + * Queue Audit - Push audit task for async processing + * + * Single method that handles all audit types using discriminated union. + * TypeScript ensures type safety between action and data. + * + * @param bookingUid - The booking UID + * @param actor - The actor performing the action + * @param organizationId - The organization ID (null for non-org bookings) + * @param actionData - Discriminated union of action type and its data + * + * @example + * await bookingAuditProducerService.queueAudit(bookingUid, actor, organizationId, { + * action: "CREATED", + * data: { startTime, endTime, status } + * }); + */ + queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise; +} + diff --git a/packages/features/booking-audit/lib/service/BookingAuditService.ts b/packages/features/booking-audit/lib/service/BookingAuditService.ts deleted file mode 100644 index d57d74262a..0000000000 --- a/packages/features/booking-audit/lib/service/BookingAuditService.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { JsonValue } from "@calcom/types/Json"; -import logger from "@calcom/lib/logger"; -import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction } from "../repository/IBookingAuditRepository"; -import type { IAuditActorRepository } from "../repository/IAuditActorRepository"; -import { safeStringify } from "@calcom/lib/safeStringify"; - -interface BookingAuditServiceDeps { - bookingAuditRepository: IBookingAuditRepository; - actorRepository: IAuditActorRepository; -} - -type CreateBookingAuditInput = { - bookingUid: string; - actorId: string; - type: BookingAuditType; - action: BookingAuditAction; - data: JsonValue; - timestamp: Date; // Required: actual time of the booking change (business event) -}; - -type BookingAudit = { - id: string; - bookingUid: string; - actorId: string; - type: BookingAuditType; - action: BookingAuditAction; - timestamp: Date; - createdAt: Date; - updatedAt: Date; - data: JsonValue; -}; - -/** - * BookingAuditService - Central service for all booking audit operations - * Handles both write (audit creation) and read (display) operations - * Each action service manages its own schema versioning - */ -export class BookingAuditService { - private readonly bookingAuditRepository: IBookingAuditRepository; - - constructor(private readonly deps: BookingAuditServiceDeps) { - this.bookingAuditRepository = deps.bookingAuditRepository; - } - - /** - * TODO: TO be integrated with public methods in followup PR - */ - async createAuditRecord(input: CreateBookingAuditInput): Promise { - // Log only non-sensitive metadata, excluding the data field which may contain PII - logger.info("Creating audit record", safeStringify({ - bookingUid: input.bookingUid, - actorId: input.actorId, - type: input.type, - action: input.action, - timestamp: input.timestamp, - })); - - return this.bookingAuditRepository.create({ - bookingUid: input.bookingUid, - actorId: input.actorId, - type: input.type, - action: input.action, - timestamp: input.timestamp, - data: input.data, - }); - } -} \ No newline at end of file diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts new file mode 100644 index 0000000000..43c34a63bc --- /dev/null +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts @@ -0,0 +1,300 @@ +import type { JsonValue } from "@calcom/types/Json"; + +import logger from "@calcom/lib/logger"; +import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface"; + +import type { Actor } from "../../../bookings/lib/types/actor"; +import type { BookingAuditTaskConsumerPayload } from "../types/bookingAuditTask"; +import { BookingAuditTaskConsumerPayloadSchema } from "../types/bookingAuditTask"; +import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService"; +import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction } from "../repository/IBookingAuditRepository"; +import type { IAuditActorRepository } from "../repository/IAuditActorRepository"; +import { safeStringify } from "@calcom/lib/safeStringify"; + +interface BookingAuditTaskConsumerDeps { + bookingAuditRepository: IBookingAuditRepository; + auditActorRepository: IAuditActorRepository; + featuresRepository: IFeaturesRepository; +} + +type CreateBookingAuditInput = { + bookingUid: string; + actorId: string; + type: BookingAuditType; + action: BookingAuditAction; + data: JsonValue; + timestamp: Date; // Required: actual time of the booking change (business event) +}; + +type BookingAudit = { + id: string; + bookingUid: string; + actorId: string; + type: BookingAuditType; + action: BookingAuditAction; + timestamp: Date; + createdAt: Date; + updatedAt: Date; + data: JsonValue; +}; + +/** + * BookingAuditTaskConsumer - Task consumer for processing booking audit tasks + * Handles all audit processing logic including feature flag checks and routing to action handlers + * Designed to be deployed separately (e.g., as trigger.dev job) with minimal dependencies + * + * Note: PENDING and AWAITING_HOST actions are intentionally not implemented. + * These represent initial booking states captured by the CREATED action. + */ +export class BookingAuditTaskConsumer { + private readonly createdActionService: CreatedAuditActionService; + private readonly bookingAuditRepository: IBookingAuditRepository; + private readonly auditActorRepository: IAuditActorRepository; + private readonly featuresRepository: IFeaturesRepository; + + constructor(private readonly deps: BookingAuditTaskConsumerDeps) { + this.bookingAuditRepository = deps.bookingAuditRepository; + this.auditActorRepository = deps.auditActorRepository; + this.featuresRepository = deps.featuresRepository; + + // Each service instantiates its own helper with its specific schema + this.createdActionService = new CreatedAuditActionService(); + } + + /** + * Process Audit Task - Entry point for task handler + * + * This method handles: + * 1. Schema validation (accepts all supported versions) + * 2. Feature flag checks + * 3. Schema migration to latest version if needed + * 4. Routing to appropriate action handlers + * + * Schema Migration: + * - Validates payload against all supported schema versions + * - Migrates old versions to latest at task boundary + * - Updates task payload in DB if migration occurs + * - Ensures retries always use latest schema version + * + * @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 + */ + async processAuditTask(payload: unknown, taskId: string): Promise { + // Validate payload schema (accepts all supported versions) + const parseResult = BookingAuditTaskConsumerPayloadSchema.safeParse(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}` + ); + return; + } + + const isFeatureEnabled = await this.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit"); + + if (!isFeatureEnabled) { + logger.info( + `booking-audit feature is disabled for organization, skipping audit processing: action=${action}, bookingUid=${bookingUid}, organizationId=${organizationId}` + ); + return; + } + + const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload: validatedPayload, taskId }); + + if (action !== "CREATED") { + throw new Error(`Unsupported audit action: ${action}`); + } + await this.onBookingAction({ bookingUid, actor, action, data: dataInLatestFormat, timestamp }); + } + + /** + * Migrate If Needed - Migrates data to latest version + * + * Calls the action service's migrateToLatest method which: + * - Validates the data against all supported versions + * - Migrates to latest version if needed + * - Returns validated data with migration status + * + * If migration occurred, updates the task payload in DB for retries. + */ + private async migrateIfNeeded(params: { + action: BookingAuditTaskConsumerPayload["action"]; + data: unknown; + payload: BookingAuditTaskConsumerPayload; + taskId: string; + }) { + const { action, data, payload, taskId } = params; + const actionService = this.getActionService(action); + + // migrateToLatest is now required - validates and migrates if needed + const migrationResult = actionService.migrateToLatest(data); + + // If migrated, update task payload in DB + if (migrationResult.isMigrated) { + logger.info( + `Schema migration performed: action=${action}` + ); + await this.updateTaskPayload(payload, migrationResult.latestData, taskId); + } + + return migrationResult.latestData; + } + + + /** + * 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 + */ + private getActionService(action: BookingAuditAction) { + if (action !== "CREATED") { + throw new Error(`Unsupported audit action: ${action}`); + } + return this.createdActionService; + } + + /** + * Update Task Payload - Updates the task payload in DB with migrated data + * + * This ensures that task retries use the latest schema version. + * When a task fails and retries, it will use the already-migrated payload. + * + * @param payload - Original task payload + * @param latestData - Migrated data in latest schema version + * @param taskId - Task ID (required for DB update) + */ + private async updateTaskPayload( + payload: BookingAuditTaskConsumerPayload, + latestData: unknown, + taskId: string + ): Promise { + 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( + `Failed to update task payload in DB: taskId=${taskId}, error=${safeStringify(error)}` + ); + } + } + + /** + * 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 { + switch (actor.identifiedBy) { + case "id": + return actor.id; + case "user": { + const userActor = await this.auditActorRepository.createIfNotExistsUserActor({ userUuid: actor.userUuid }); + 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}`); + } + return attendeeActor.id; + } + case "guest": { + const guestActor = await this.auditActorRepository.createIfNotExistsGuestActor( + actor.email ?? null, + actor.name ?? null, + actor.phone ?? null + ); + return guestActor.id; + } + } + } + + /** + * Creates a booking audit record + * Action services handle their own version wrapping + */ + private async createAuditRecord(input: CreateBookingAuditInput): Promise { + logger.info("Creating audit record", safeStringify({ + bookingUid: input.bookingUid, + actorId: input.actorId, + type: input.type, + action: input.action, + timestamp: input.timestamp, + })); + + return this.bookingAuditRepository.create({ + bookingUid: input.bookingUid, + actorId: input.actorId, + type: input.type, + action: input.action, + timestamp: input.timestamp, + data: input.data ?? null, + }); + } + + /** + * Get Record Type - Derives the record type from the action + * + * Maps booking actions to their corresponding audit record types: + * - CREATED → RECORD_CREATED + * - Future actions like CANCELLED, RESCHEDULED → RECORD_UPDATED + * - Future actions like DELETED → RECORD_DELETED + * + * @param action - The booking audit action + * @returns The corresponding record type + */ + private getRecordType(params: { action: BookingAuditAction }): BookingAuditType { + const { action } = params; + + switch (action) { + case "CREATED": + return "RECORD_CREATED"; + // Future actions will map to RECORD_UPDATED or RECORD_DELETED + default: + throw new Error(`Unsupported action for record type mapping: ${action}`); + } + } + + async onBookingAction(params: { + bookingUid: string; + actor: Actor; + action: BookingAuditAction; + data: CreatedAuditData; + timestamp: number; + }): Promise { + const { bookingUid, actor, action, data, timestamp } = params; + const actionService = this.getActionService(action); + const versionedData = actionService.getVersionedData(data); + const actorId = await this.resolveActorId(actor); + const recordType = this.getRecordType({ action }); + + return this.createAuditRecord({ + bookingUid, + actorId, + type: recordType, + action, + data: versionedData, + timestamp: new Date(timestamp), + }); + } +} + diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts new file mode 100644 index 0000000000..028a111b79 --- /dev/null +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts @@ -0,0 +1,59 @@ +import type { Tasker } from "@calcom/features/tasker/tasker"; + +import type { Actor } from "../../../bookings/lib/types/actor"; +import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask"; +import type { BookingAuditProducerService } from "./BookingAuditProducerService.interface"; + +interface BookingAuditTaskerProducerServiceDeps { + tasker: Tasker; +} + +/** + * BookingAuditTaskerProducerService - Tasker-based implementation of BookingAuditProducerService + * + * Producer that uses Tasker for local/background job processing. + * Task processing is handled by BookingAuditTaskConsumer. + * + * For future migration to trigger.dev, create BookingAuditTriggerProducerService + * that implements the same BookingAuditProducerService interface. + */ +export class BookingAuditTaskerProducerService implements BookingAuditProducerService { + private readonly tasker: Tasker; + + constructor(private readonly deps: BookingAuditTaskerProducerServiceDeps) { + this.tasker = deps.tasker; + } + + /** + * Queue Audit - Push audit task to Tasker for async processing + * + * Single method that handles all audit types using discriminated union. + * TypeScript ensures type safety between action and data. + * Timestamp is automatically captured when the task is queued. + * + * @param bookingUid - The booking UID + * @param actor - The actor performing the action + * @param organizationId - The organization ID (null for non-org bookings) + * @param actionData - Discriminated union of action type and its data + * + * @example + * await bookingAuditProducerService.queueAudit(bookingUid, actor, organizationId, { + * action: "CREATED", + * data: { startTime, endTime, status } + * }); + */ + async queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise { + // Skip queueing for non-organization bookings + if (organizationId === null) { + return; + } + + await this.tasker.create("bookingAudit", { + bookingUid, + actor, + organizationId, + timestamp: Date.now(), + ...actionData, + }); + } +} diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts new file mode 100644 index 0000000000..923df9de76 --- /dev/null +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -0,0 +1,179 @@ +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; + +import { CreatedAuditActionService, type CreatedAuditDisplayData } from "../actions/CreatedAuditActionService"; +import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; + +type AuditDisplayData = CreatedAuditDisplayData; + +interface BookingAuditViewerServiceDeps { + bookingAuditRepository: IBookingAuditRepository; + userRepository: UserRepository; +} + +type EnrichedAuditLog = { + id: string; + bookingUid: string; + type: BookingAuditType; + action: BookingAuditAction; + timestamp: string; + createdAt: string; + data: AuditDisplayData; + actor: { + id: string; + type: string; + userUuid: string | null; + attendeeId: number | null; + name: string | null; + createdAt: Date; + displayName: string; + displayEmail: string | null; + displayAvatar: string | null; + }; +}; + +/** + * BookingAuditViewerService - Service for viewing and formatting booking audit logs + */ +export class BookingAuditViewerService { + private readonly createdActionService: CreatedAuditActionService; + private readonly bookingAuditRepository: IBookingAuditRepository; + private readonly userRepository: UserRepository; + + constructor(private readonly deps: BookingAuditViewerServiceDeps) { + this.bookingAuditRepository = deps.bookingAuditRepository; + this.userRepository = deps.userRepository; + + this.createdActionService = new CreatedAuditActionService(); + } + + /** + * Get audit logs for a booking with full enrichment and formatting + * Handles permission checks, fetches logs, enriches actors, and formats display + */ + async getAuditLogsForBooking( + bookingUid: string, + _userId: number, + _userEmail: string + ): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> { + // Check permissions + await this.checkPermissions(); + + // Fetch audit logs + const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid); + + // Enrich and format audit logs + const enrichedAuditLogs = await Promise.all( + auditLogs.map(async (log) => { + const enrichedActor = await this.enrichActorInformation(log.actor); + + const actionService = this.getActionService(log.action); + const parsedData = actionService.parseStored(log.data); + + return { + id: log.id, + bookingUid: log.bookingUid, + type: log.type, + action: log.action, + timestamp: log.timestamp.toISOString(), + createdAt: log.createdAt.toISOString(), + data: actionService.getDisplayJson(parsedData), + actor: { + ...log.actor, + displayName: enrichedActor.displayName, + displayEmail: enrichedActor.displayEmail, + displayAvatar: enrichedActor.displayAvatar, + }, + }; + }) + ); + + return { + bookingUid, + auditLogs: enrichedAuditLogs, + }; + } + + /** + * Check if user has permission to view audit logs for a booking + */ + private async checkPermissions(): Promise { + // TODO: Implement permission check + if (IS_PRODUCTION) { + throw new Error("Permission check is not implemented for production environments"); + } + } + + /** + * 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 + */ + private getActionService(action: BookingAuditAction) { + if (action !== "CREATED") { + throw new Error(`Unsupported audit action: ${action}`); + } + return this.createdActionService; + } + + /** + * Enrich actor information with user details if userUuid exists + */ + private async enrichActorInformation(actor: BookingAuditWithActor["actor"]): Promise<{ + displayName: string; + 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) { + 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}`); + } +} + diff --git a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts new file mode 100644 index 0000000000..8ad0a8b2bd --- /dev/null +++ b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; + +import { prisma } from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import type { BookingAuditTaskConsumer } from "./BookingAuditTaskConsumer"; +import type { BookingAuditViewerService } from "./BookingAuditViewerService"; +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 + */ +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; + + 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; + + // 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 booking + const startTime = new Date(); + const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); + testBookingUid = `test-booking-${timestamp}-${randomSuffix}`; + + const testBooking = await prisma.booking.create({ + data: { + uid: testBookingUid, + title: "Test Booking", + startTime, + endTime, + userId: testUserId, + eventTypeId: testEventTypeId, + status: BookingStatus.ACCEPTED, + }, + }); + + // Create test attendee user for permission tests + 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; + + // Add attendee to booking + await prisma.attendee.create({ + data: { + email: testAttendeeEmail, + name: "Test Attendee", + timeZone: "UTC", + bookingId: testBooking.id, + }, + }); + }); + + afterEach(async () => { + // Clean up test data in correct order to respect foreign key constraints + + // 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], + }, + }, + }); + }); + + 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, + }, + }); + + expect(booking).toBeDefined(); + + // Create actor using helper + const actor = makeUserActor(testUserUuid); + + // Act: Create audit record using TaskConsumer + await bookingAuditTaskConsumer.onBookingAction({ + bookingUid: testBookingUid, + actor, + action: "CREATED", + data: { + startTime: booking!.startTime.getTime(), + endTime: booking!.endTime.getTime(), + status: booking!.status, + }, + timestamp: Date.now(), + }); + + // Retrieve audit logs + const result = await bookingAuditViewerService.getAuditLogsForBooking( + testBookingUid, + testUserId, + testUserEmail + ); + + // Assert: Verify audit log exists and has correct data + expect(result.bookingUid).toBe(testBookingUid); + expect(result.auditLogs).toHaveLength(1); + + const auditLog = result.auditLogs[0]; + expect(auditLog.bookingUid).toBe(testBookingUid); + expect(auditLog.action).toBe("CREATED"); + expect(auditLog.type).toBe("RECORD_CREATED"); + + // Verify audit data matches (getDisplayJson returns fields only, not versioned wrapper) + const displayData = auditLog.data + expect(displayData.startTime).toBe(booking!.startTime.toISOString()); + expect(displayData.endTime).toBe(booking!.endTime.toISOString()); + expect(displayData.status).toBe(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 }, + }); + + // Create actor using helper + const actor = makeUserActor(testUserUuid); + + // Create audit record using TaskConsumer + await bookingAuditTaskConsumer.onBookingAction({ + bookingUid: testBookingUid, + actor, + action: "CREATED", + data: { + startTime: booking!.startTime.getTime(), + endTime: booking!.endTime.getTime(), + status: booking!.status, + }, + timestamp: Date.now(), + }); + + // Act: Retrieve audit logs + const result = await bookingAuditViewerService.getAuditLogsForBooking( + testBookingUid, + testUserId, + testUserEmail + ); + + // 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); + }); + + 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 }, + }); + + // Create actor using helper + const actor = makeUserActor(testUserUuid); + + // Create audit record using TaskConsumer + await bookingAuditTaskConsumer.onBookingAction({ + bookingUid: testBookingUid, + actor, + action: "CREATED", + data: { + startTime: booking!.startTime.getTime(), + endTime: booking!.endTime.getTime(), + status: booking!.status, + }, + timestamp: Date.now(), + }); + + // Act & Assert: Booking owner can view + const ownerResult = await bookingAuditViewerService.getAuditLogsForBooking( + testBookingUid, + testUserId, + testUserEmail + ); + 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"); + }); + }); +}); + diff --git a/packages/features/booking-audit/lib/types/bookingAuditTask.ts b/packages/features/booking-audit/lib/types/bookingAuditTask.ts new file mode 100644 index 0000000000..2e1aca41fe --- /dev/null +++ b/packages/features/booking-audit/lib/types/bookingAuditTask.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { ActorSchema } from "@calcom/features/bookings/lib/types/actor"; +import { + CreatedAuditActionService, +} from "../actions/CreatedAuditActionService"; + + +const baseSchema = z.object({ + bookingUid: z.string(), + actor: ActorSchema, + organizationId: z.number().nullable(), + timestamp: z.number(), +}); + +export const BookingAuditTaskConsumerPayloadSchema = z.discriminatedUnion("action", [ + baseSchema.merge(z.object({ + action: z.literal(CreatedAuditActionService.TYPE), + // Payload in Task record could have any version of the data schema + data: CreatedAuditActionService.storedFieldsSchema, + })), + // ... more actions here +]); + +export type BookingAuditTaskProducerActionData = + | { action: typeof CreatedAuditActionService.TYPE; data: z.infer } +// ... more actions here + +export type BookingAuditTaskConsumerPayload = z.infer; diff --git a/packages/features/bookings/di/BookingEventHandlerService.module.ts b/packages/features/bookings/di/BookingEventHandlerService.module.ts index b7be1b0856..d3583f8083 100644 --- a/packages/features/bookings/di/BookingEventHandlerService.module.ts +++ b/packages/features/bookings/di/BookingEventHandlerService.module.ts @@ -2,7 +2,7 @@ import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBook import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { moduleLoader as hashedLinkServiceModuleLoader } from "@calcom/features/hashedLink/di/HashedLinkService.module"; -import { moduleLoader as bookingAuditServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditService.module"; +import { moduleLoader as bookingAuditProducerServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditTaskerProducerService.module"; import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service"; const thisModule = createModule(); @@ -16,7 +16,7 @@ const loadModule = bindModuleToClassOnToken({ classs: BookingEventHandlerService, depsMap: { hashedLinkService: hashedLinkServiceModuleLoader, - bookingAuditService: bookingAuditServiceModuleLoader, + bookingAuditProducerService: bookingAuditProducerServiceModuleLoader, log: loggerModuleLoader, }, }); @@ -27,4 +27,3 @@ export const moduleLoader = { }; export type { BookingEventHandlerService }; - diff --git a/packages/features/bookings/di/RegularBookingService.module.ts b/packages/features/bookings/di/RegularBookingService.module.ts index 3131efc37c..ac83a0c795 100644 --- a/packages/features/bookings/di/RegularBookingService.module.ts +++ b/packages/features/bookings/di/RegularBookingService.module.ts @@ -1,4 +1,3 @@ -import { moduleLoader as bookingAuditServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditService.module"; import { moduleLoader as bookingEventHandlerModuleLoader } from "@calcom/features/bookings/di/BookingEventHandlerService.module"; import { RegularBookingService } from "@calcom/features/bookings/lib/service/RegularBookingService"; import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts index 824b070ec9..ea84e90166 100644 --- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -1,32 +1,47 @@ -import type { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService"; +import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface"; 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 { log: ISimpleLogger; hashedLinkService: HashedLinkService; - //TODO: To be made required in followup PR - bookingAuditService?: BookingAuditService; + bookingAuditProducerService: BookingAuditProducerService; } export class BookingEventHandlerService { private readonly log: BookingEventHandlerDeps["log"]; - private readonly hashedLinkService: BookingEventHandlerDeps["hashedLinkService"]; - constructor(private readonly deps: BookingEventHandlerDeps) { this.log = deps.log; - this.hashedLinkService = deps.hashedLinkService; } - async onBookingCreated(payload: BookingCreatedPayload) { + async onBookingCreated(payload: BookingCreatedPayload, actor: Actor) { 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)); + } } async onBookingRescheduled(payload: BookingRescheduledPayload) { diff --git a/packages/features/bookings/lib/onBookingEvents/types.d.ts b/packages/features/bookings/lib/onBookingEvents/types.d.ts index 9170dc0c86..c3e2c0cbce 100644 --- a/packages/features/bookings/lib/onBookingEvents/types.d.ts +++ b/packages/features/bookings/lib/onBookingEvents/types.d.ts @@ -1,11 +1,26 @@ -import type { BookingFlowConfig } from "./dto/types"; +import type { BookingFlowConfig } from "../dto/types"; +import type { BookingStatus } from "@calcom/prisma/enums"; export interface BookingCreatedPayload { config: BookingFlowConfig; bookingFormData: { hashedLink: string | null; }; + booking: { + uid: string; + startTime: Date; + endTime: Date; + status: BookingStatus; + userId: number | null; + user?: { + id: number; + }; + }; + organizationId: number | null; } - -// Add more fields here when needed -type BookingRescheduledPayload = BookingCreatedPayload; +export interface BookingRescheduledPayload extends BookingCreatedPayload { + oldBooking?: { + startTime: Date; + endTime: Date; + }; +} \ No newline at end of file diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 01c3cce1ac..ea20654d5e 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -36,6 +36,7 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker"; +import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d"; import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; @@ -126,6 +127,7 @@ import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validat import { validateEventLength } from "../handleNewBooking/validateEventLength"; import handleSeats from "../handleSeats/handleSeats"; import type { IBookingService } from "../interfaces/IBookingService"; +import { makeGuestActor } from "../types/actor"; const translator = short(); @@ -412,6 +414,49 @@ function formatAvailabilitySnapshot(data: { }; } +function buildBookingCreatedPayload({ + booking, + organizerUserId, + hashedLink, + isDryRun, + organizationId, +}: { + booking: { + id: number; + uid: string; + startTime: Date; + endTime: Date; + status: BookingStatus; + userId: number | null; + }; + organizerUserId: number; + hashedLink: string | null; + isDryRun: boolean; + organizationId: number | null; +}) { + return { + config: { + isDryRun, + }, + bookingFormData: { + hashedLink, + }, + booking: { + id: booking.id, + uid: booking.uid, + startTime: booking.startTime, + endTime: booking.endTime, + status: booking.status, + userId: booking.userId, + user: { + id: organizerUserId, + }, + }, + organizationId, + }; +} + + export interface IBookingServiceDependencies { checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService; prismaClient: PrismaClient; @@ -1265,9 +1310,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); @@ -1313,8 +1358,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) { @@ -1935,14 +1980,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. @@ -2243,32 +2288,36 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, - } + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, + } : undefined; - const bookingFlowConfig = { + const bookingCreatedPayload = buildBookingCreatedPayload({ + booking, + organizerUserId: organizerUser.id, + // FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely + hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null, isDryRun, - }; + organizationId: eventOrganizationId, + }); - const bookingCreatedPayload = { - config: bookingFlowConfig, - bookingFormData: { - // FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely - hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null, - }, + const bookingRescheduledPayload: BookingRescheduledPayload = { + ...bookingCreatedPayload, + oldBooking: originalRescheduledBooking ? { + startTime: originalRescheduledBooking.startTime, + endTime: originalRescheduledBooking.endTime, + } : undefined, }; - // Add more fields here when needed - const bookingRescheduledPayload = bookingCreatedPayload; - const bookingEventHandler = deps.bookingEventHandler; // TODO: Incrementally move all stuff that happens after a booking is created to these handlers if (originalRescheduledBooking) { await bookingEventHandler.onBookingRescheduled(bookingRescheduledPayload); } else { - await bookingEventHandler.onBookingCreated(bookingCreatedPayload); + // 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); } const webhookData: EventPayloadType = { @@ -2333,9 +2382,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, @@ -2669,7 +2718,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); diff --git a/packages/features/bookings/lib/types/actor.ts b/packages/features/bookings/lib/types/actor.ts index ed08805987..118cf72115 100644 --- a/packages/features/bookings/lib/types/actor.ts +++ b/packages/features/bookings/lib/types/actor.ts @@ -1,49 +1,38 @@ -/** - * Represents the entity that performed a booking action - * Uses discriminating union for type-safe actor identification - */ -export type Actor = ActorById | UserActor | AttendeeActor | GuestActor; +import { z } from "zod"; -/** - * Actor referenced by existing ID in AuditActor table - * Use this for System actors or when you already have an actor ID - */ -type ActorById = { - identifiedBy: "id"; - id: string; // UUID of existing actor (including System actor) -}; +const UserActorSchema = z.object({ + identifiedBy: z.literal("user"), + userUuid: z.string(), +}); -/** - * Actor identified by user UUID - * Will be upserted in AuditActor table - */ -type UserActor = { - identifiedBy: "user"; - userUuid: string; -}; +const GuestActorSchema = z.object({ + identifiedBy: z.literal("guest"), + email: z.string().optional(), + name: z.string().optional(), + phone: z.string().optional(), +}); -/** - * Actor identified by attendee ID - * Must already exist in AuditActor table - */ -type AttendeeActor = { - identifiedBy: "attendee"; - attendeeId: number; -}; +const AttendeeActorSchema = z.object({ + identifiedBy: z.literal("attendee"), + attendeeId: z.number(), +}); -/** - * Actor identified by guest data (email, name, phone) - * Will be upserted in AuditActor table - */ -type GuestActor = { - identifiedBy: "guest"; - email?: string; - name?: string; - phone?: string; -}; +const ActorByIdSchema = z.object({ + identifiedBy: z.literal("id"), + id: z.string(), +}); -// System actor ID constant -export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; +export const ActorSchema = z.discriminatedUnion("identifiedBy", [ + ActorByIdSchema, + UserActorSchema, + GuestActorSchema, + AttendeeActorSchema, +]); + +export type Actor = z.infer; +type UserActor = z.infer; +type GuestActor = z.infer; +type ActorById = z.infer; /** * Creates an Actor representing a User by UUID @@ -69,12 +58,16 @@ export function makeSystemActor(): ActorById { /** * Creates an Actor representing a Guest (non-registered attendee) */ -export function makeGuestActor(email: string, name?: string, phone?: string): GuestActor { +export function makeGuestActor(params: { + email: string; + name?: string; + phone?: string; +}): GuestActor { return { identifiedBy: "guest", - email, - name, - phone, + email: params.email, + name: params.name, + phone: params.phone, }; } @@ -86,4 +79,7 @@ export function makeActorById(id: string): ActorById { identifiedBy: "id", id, }; -} \ No newline at end of file +} + +// System actor ID constant +export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; \ No newline at end of file diff --git a/packages/features/di/shared/services/tasker.service.ts b/packages/features/di/shared/services/tasker.service.ts index 0b96d937e2..0f90fea382 100644 --- a/packages/features/di/shared/services/tasker.service.ts +++ b/packages/features/di/shared/services/tasker.service.ts @@ -1,13 +1,23 @@ import { createModule } from "@evyweb/ioctopus"; +import type { Container } from "@evyweb/ioctopus"; +import tasker from "@calcom/features/tasker"; import type { ITasker } from "@calcom/features/webhooks/lib/interface/infrastructure"; import { SHARED_TOKENS } from "../shared.tokens"; export const taskerServiceModule = createModule(); +const token = SHARED_TOKENS.TASKER; -// Bind tasker with proper async factory that respects IoC -taskerServiceModule.bind(SHARED_TOKENS.TASKER).toFactory(async (): Promise => { - const taskerModule = await import("@calcom/features/tasker"); - return taskerModule.default; +taskerServiceModule.bind(SHARED_TOKENS.TASKER).toFactory((): ITasker => { + return tasker; }); + +const loadModule = (container: Container) => { + container.load(SHARED_TOKENS.TASKER, taskerServiceModule); +}; + +export const moduleLoader = { + token, + loadModule, +}; \ No newline at end of file diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 7a568ef84a..9a15789484 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -33,6 +33,7 @@ export type AppFlags = { "booking-calendar-view": boolean; "booking-email-sms-tasker": boolean; "bookings-v3": boolean; + "booking-audit": boolean; }; export type TeamFeatures = Record; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index ae8cef35e6..be85420d87 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -32,6 +32,7 @@ const initialData: AppFlags = { "booking-calendar-view": false, "booking-email-sms-tasker": false, "bookings-v3": false, + "booking-audit": false, }; if (process.env.NEXT_PUBLIC_IS_E2E) { diff --git a/packages/features/tasker/repository.ts b/packages/features/tasker/repository.ts index 9e34857d4e..1a03803cbd 100644 --- a/packages/features/tasker/repository.ts +++ b/packages/features/tasker/repository.ts @@ -1,4 +1,5 @@ -import db from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import type { PrismaClient } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import { type TaskTypes } from "./tasker"; @@ -36,15 +37,21 @@ const makeWhereUpcomingTasks = (): Prisma.TaskWhereInput => ({ }, }); -export class Task { - static async create( +type Dependencies = { + prismaClient: PrismaClient; +}; + +export class TaskRepository { + constructor(private readonly deps: Dependencies) { } + + async create( type: TaskTypes, payload: string, options: { scheduledAt?: Date; maxAttempts?: number; referenceUid?: string } = {} ) { const { scheduledAt, maxAttempts, referenceUid } = options; console.info("Creating task", { type, payload, scheduledAt, maxAttempts }); - const newTask = await db.task.create({ + const newTask = await this.deps.prismaClient.task.create({ data: { payload, type, @@ -56,9 +63,9 @@ export class Task { return newTask.id; } - static async getNextBatch() { + async getNextBatch() { console.info("Getting next batch of tasks", makeWhereUpcomingTasks()); - return db.task.findMany({ + return this.deps.prismaClient.task.findMany({ where: makeWhereUpcomingTasks(), orderBy: { scheduledAt: "asc", @@ -67,41 +74,41 @@ export class Task { }); } - static async getFailed() { - return db.task.findMany({ + async getFailed() { + return this.deps.prismaClient.task.findMany({ where: whereMaxAttemptsReached, }); } - static async getSucceeded() { - return db.task.findMany({ + async getSucceeded() { + return this.deps.prismaClient.task.findMany({ where: whereSucceeded, }); } - static async count() { - return db.task.count(); + async count() { + return this.deps.prismaClient.task.count(); } - static async countUpcoming() { - return db.task.count({ + async countUpcoming() { + return this.deps.prismaClient.task.count({ where: makeWhereUpcomingTasks(), }); } - static async countFailed() { - return db.task.count({ + async countFailed() { + return this.deps.prismaClient.task.count({ where: whereMaxAttemptsReached, }); } - static async countSucceeded() { - return db.task.count({ + async countSucceeded() { + return this.deps.prismaClient.task.count({ where: whereSucceeded, }); } - static async retry({ + async retry({ taskId, lastError, minRetryIntervalMins, @@ -115,7 +122,7 @@ export class Task { ? new Date(failedAttemptTime.getTime() + 1000 * 60 * minRetryIntervalMins) : undefined; - return db.task.update({ + return this.deps.prismaClient.task.update({ where: { id: taskId, }, @@ -130,8 +137,8 @@ export class Task { }); } - static async succeed(taskId: string) { - return db.task.update({ + async succeed(taskId: string) { + return this.deps.prismaClient.task.update({ where: { id: taskId, }, @@ -142,18 +149,35 @@ export class Task { }); } - static async cancel(taskId: string) { - return db.task.delete({ + /** + * Update the payload of a task + * + * @param taskId - The ID of the task to update + * @param newPayload - The new payload string + */ + async updatePayload(taskId: string, newPayload: string) { + return this.deps.prismaClient.task.update({ + where: { + id: taskId, + }, + data: { + payload: newPayload, + }, + }); + } + + async cancel(taskId: string) { + return this.deps.prismaClient.task.delete({ where: { id: taskId, }, }); } - static async cancelWithReference(referenceUid: string, type: TaskTypes): Promise<{ id: string } | null> { - // db.task.delete throws an error if the task does not exist, so we catch it and return null + async cancelWithReference(referenceUid: string, type: TaskTypes): Promise<{ id: string } | null> { + // prismaClient.task.delete throws an error if the task does not exist, so we catch it and return null try { - return await db.task.delete({ + return await this.deps.prismaClient.task.delete({ where: { referenceUid_type: { referenceUid, @@ -174,9 +198,9 @@ export class Task { } } - static async cleanup() { + async cleanup() { // TODO: Uncomment this later - // return db.task.deleteMany({ + // return this.deps.prismaClient.task.deleteMany({ // where: { // OR: [ // // Get tasks that have succeeded @@ -188,8 +212,8 @@ export class Task { // }); } - static async hasNewerScanTaskForStepId(workflowStepId: number, createdAt: string) { - const tasks = await db.$queryRaw<{ payload: string }[]>` + async hasNewerScanTaskForStepId(workflowStepId: number, createdAt: string) { + const tasks = await this.deps.prismaClient.$queryRaw<{ payload: string }[]>` SELECT "payload" FROM "Task" WHERE "type" = 'scanWorkflowBody' @@ -208,3 +232,7 @@ export class Task { }); } } + +// Export singleton instance for backward compatibility +// This allows existing code using Task.create(), Task.succeed(), etc. to continue working +export const Task = new TaskRepository({ prismaClient: prisma }); diff --git a/packages/features/tasker/task-processor.ts b/packages/features/tasker/task-processor.ts index 491b696c30..6111103286 100644 --- a/packages/features/tasker/task-processor.ts +++ b/packages/features/tasker/task-processor.ts @@ -20,7 +20,7 @@ export class TaskProcessor { if (!taskHandlerGetter) throw new Error(`Task handler not found for type ${task.type}`); const taskConfig = tasksConfig[task.type as keyof typeof tasksConfig]; const taskHandler = await taskHandlerGetter(); - return taskHandler(task.payload) + return taskHandler(task.payload, task.id) .then(async () => { await Task.succeed(task.id); }) diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index 76d4a11649..d66d2650da 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -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 { BookingAuditTaskConsumerPayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask"; export type TaskerTypes = "internal" | "redis"; type TaskPayloads = { sendWebhook: string; @@ -37,9 +37,10 @@ type TaskPayloads = { responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null; routedEventTypeId?: number | null; }; + bookingAudit: BookingAuditTaskConsumerPayload; }; export type TaskTypes = keyof TaskPayloads; -export type TaskHandler = (payload: string) => Promise; +export type TaskHandler = (payload: string, taskId?: string) => Promise; export type TaskerCreate = ( type: TaskKey, payload: TaskPayloads[TaskKey], diff --git a/packages/features/tasker/tasks/bookingAudit.ts b/packages/features/tasker/tasks/bookingAudit.ts new file mode 100644 index 0000000000..3184ef498e --- /dev/null +++ b/packages/features/tasker/tasks/bookingAudit.ts @@ -0,0 +1,35 @@ +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getBookingAuditTaskConsumer } from "@calcom/features/booking-audit/di/BookingAuditTaskConsumer.container"; + +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. + * + */ +export async function bookingAudit(payload: string, taskId?: string): Promise { + try { + if (!taskId) { + throw new Error("Task ID is required for booking audit consumer"); + } + const parsedPayload: unknown = JSON.parse(payload); + + log.info(`Processing booking audit: taskId=${taskId}`); + + const bookingAuditTaskConsumer = getBookingAuditTaskConsumer(); + + await bookingAuditTaskConsumer.processAuditTask(parsedPayload, taskId); + + log.info(`Successfully processed booking audit: taskId=${taskId}`); + } catch (error) { + const errorMsg = `Error processing booking audit: ${safeStringify(error)} | TaskId: ${taskId}`; + log.error(errorMsg); + // Rethrow to trigger retry via Tasker + throw error; + } +} + diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index 9ae6b79788..f60c5e15c1 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -30,6 +30,7 @@ const tasks: Record Promise> = { sendAnalyticsEvent: () => import("./analytics/sendAnalyticsEvent").then((module) => module.sendAnalyticsEvent), executeAIPhoneCall: () => import("./executeAIPhoneCall").then((module) => module.executeAIPhoneCall), + bookingAudit: () => import("./bookingAudit").then((module) => module.bookingAudit), }; export const tasksConfig = { diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index a2a56263ff..fea93ffd5b 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -123,7 +123,7 @@ const userSelect = { } satisfies Prisma.UserSelect; export class UserRepository { - constructor(private prismaClient: PrismaClient) {} + constructor(private prismaClient: PrismaClient) { } async findTeamsByUserId({ userId }: { userId: UserType["id"] }) { const teamMemberships = await this.prismaClient.membership.findMany({ @@ -236,35 +236,35 @@ export class UserRepository { // Lookup in profiles because that's where the organization usernames exist const profiles = orgSlug ? ( - await ProfileRepository.findManyByOrgSlugOrRequestedSlug({ - orgSlug: orgSlug, - usernames: usernameList, - }) - ).map((profile) => ({ - ...profile, - organization: getParsedTeam(profile.organization), - })) + await ProfileRepository.findManyByOrgSlugOrRequestedSlug({ + orgSlug: orgSlug, + usernames: usernameList, + }) + ).map((profile) => ({ + ...profile, + organization: getParsedTeam(profile.organization), + })) : null; const where = profiles && profiles.length > 0 ? { - // Get UserIds from profiles - id: { - in: profiles.map((profile) => profile.user.id), - }, - } + // Get UserIds from profiles + id: { + in: profiles.map((profile) => profile.user.id), + }, + } : { - username: { - in: usernameList, - }, - ...(orgSlug - ? { - organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), - } - : { - organization: null, - }), - }; + username: { + in: usernameList, + }, + ...(orgSlug + ? { + organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), + } + : { + organization: null, + }), + }; return { where, profiles }; } @@ -388,6 +388,19 @@ export class UserRepository { }; } + async findByUuid({ uuid }: { uuid: string }) { + return this.prismaClient.user.findUnique({ + where: { + uuid, + }, + select: { + name: true, + email: true, + avatarUrl: true, + }, + }); + } + async findByIds({ ids }: { ids: number[] }) { return this.prismaClient.user.findMany({ where: { @@ -668,27 +681,27 @@ export class UserRepository { async enrichEntityWithProfile< T extends - | { - profile: { - id: number; - username: string | null; - organizationId: number | null; - organization?: { - id: number; - name: string; - calVideoLogo?: string | null; - bannerUrl: string | null; - slug: string | null; - metadata: Prisma.JsonValue; - }; - }; - } - | { - user: { - username: string | null; - id: number; - }; - } + | { + profile: { + id: number; + username: string | null; + organizationId: number | null; + organization?: { + id: number; + name: string; + calVideoLogo?: string | null; + bannerUrl: string | null; + slug: string | null; + metadata: Prisma.JsonValue; + }; + }; + } + | { + user: { + username: string | null; + id: number; + }; + } >(entity: T) { if ("profile" in entity) { const { profile, ...entityWithoutProfile } = entity; @@ -701,11 +714,11 @@ export class UserRepository { ...profileWithoutOrganization, ...(parsedOrg ? { - organization: parsedOrg, - } + organization: parsedOrg, + } : { - organization: null, - }), + organization: null, + }), }, }; return ret; @@ -741,10 +754,10 @@ export class UserRepository { data: { movedToProfile: data.movedToProfileId ? { - connect: { - id: data.movedToProfileId, - }, - } + connect: { + id: data.movedToProfileId, + }, + } : undefined, }, }); @@ -790,15 +803,15 @@ export class UserRepository { locked, ...(organizationIdValue ? { - organizationId: organizationIdValue, - profiles: { - create: { - username, - organizationId: organizationIdValue, - uid: ProfileRepository.generateProfileUid(), - }, + organizationId: organizationIdValue, + profiles: { + create: { + username, + organizationId: organizationIdValue, + uid: ProfileRepository.generateProfileUid(), }, - } + }, + } : {}), ...rest, }, diff --git a/packages/platform/libraries/bookings.ts b/packages/platform/libraries/bookings.ts index cc86632a01..03bece8a36 100644 --- a/packages/platform/libraries/bookings.ts +++ b/packages/platform/libraries/bookings.ts @@ -17,3 +17,4 @@ export { BookingEmailAndSmsSyncTasker } from "@calcom/features/bookings/lib/task export { BookingEmailAndSmsTriggerDevTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTriggerTasker"; 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"; diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 37ac05ec70..add140456e 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -130,3 +130,5 @@ export { checkEmailVerificationRequired } from "@calcom/trpc/server/routers/publ export { TeamService } from "@calcom/features/ee/teams/services/teamService"; export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; +export { getTasker } from "@calcom/features/tasker/tasker-factory"; +export type { Tasker } from "@calcom/features/tasker/tasker"; diff --git a/packages/prisma/migrations/20251201113559_add_booking_audit_feature_flag/migration.sql b/packages/prisma/migrations/20251201113559_add_booking_audit_feature_flag/migration.sql new file mode 100644 index 0000000000..87a02ff563 --- /dev/null +++ b/packages/prisma/migrations/20251201113559_add_booking_audit_feature_flag/migration.sql @@ -0,0 +1,10 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'booking-audit', + false, + 'Enable booking audit trails - Track all booking actions and changes for organizations', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING; + diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 6a6bfb6254..a2f8f0a128 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -8,22 +8,11 @@ import { ZFindInputSchema } from "./find.schema"; import { ZGetInputSchema } from "./get.schema"; import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema"; import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema"; +import { ZGetAuditLogsInputSchema } from "./getAuditLogs.schema"; import { ZReportBookingInputSchema } from "./reportBooking.schema"; import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema"; import { bookingsProcedure } from "./util"; -type BookingsRouterHandlerCache = { - get?: typeof import("./get.handler").getHandler; - requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler; - editLocation?: typeof import("./editLocation.handler").editLocationHandler; - addGuests?: typeof import("./addGuests.handler").addGuestsHandler; - confirm?: typeof import("./confirm.handler").confirmHandler; - getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler; - find?: typeof import("./find.handler").getHandler; - getInstantBookingLocation?: typeof import("./getInstantBookingLocation.handler").getHandler; - reportBooking?: typeof import("./reportBooking.handler").reportBookingHandler; -}; - export const bookingsRouter = router({ get: authedProcedure.input(ZGetInputSchema).query(async ({ input, ctx }) => { const { getHandler } = await import("./get.handler"); @@ -109,4 +98,12 @@ export const bookingsRouter = router({ input, }); }), + getAuditLogs: authedProcedure.input(ZGetAuditLogsInputSchema).query(async ({ input, ctx }) => { + const { getAuditLogsHandler } = await import("./getAuditLogs.handler"); + + return getAuditLogsHandler({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts new file mode 100644 index 0000000000..961d2bc375 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts @@ -0,0 +1,30 @@ +import type { PrismaClient } from "@calcom/prisma/client"; + +import { getBookingAuditViewerService } from "@calcom/features/booking-audit/di/BookingAuditViewerService.container"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TGetAuditLogsInputSchema } from "./getAuditLogs.schema"; + +type GetAuditLogsOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetAuditLogsInputSchema; +}; + +export const getAuditLogsHandler = async ({ ctx, input }: GetAuditLogsOptions) => { + const { user } = ctx; + const { bookingUid } = input; + + const bookingAuditViewerService = getBookingAuditViewerService(); + + const result = await bookingAuditViewerService.getAuditLogsForBooking( + bookingUid, + user.id, + user.email + ); + + return result; +}; + diff --git a/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts new file mode 100644 index 0000000000..9ed51a0328 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetAuditLogsInputSchema = z.object({ + bookingUid: z.string(), +}); + +export type TGetAuditLogsInputSchema = z.infer; +