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 d7f34a96eb8203..c52d00c72b9a9d 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 00000000000000..f6b7f49b5f1de7 --- /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 5a9f49dd94a251..dc968dd14fc167 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 00000000000000..a1520c5a8ab495 --- /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 00000000000000..5d5a072db4d17c --- /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 00000000000000..a954ed4631e35e --- /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; + onTypeFilterChange(option.value || null); + }} + options={[{ label: `${t("type")}: ${t("all")}`, value: "" }, ...typeOptions.map(opt => ({ ...opt, label: `${t("type")}: ${opt.label}` }))]} + /> + + + + { + 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()} + + + + + + + + + + toggleExpand(log.id)} + StartIcon={isExpanded ? "chevron-down" : "chevron-right"} + className="w-full justify-start text-xs font-medium text-subtle h-6"> + {isExpanded ? t("hide_details") : t("show_details")} + + + + + {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 && ( + + toggleJson(log.id)} + StartIcon={showJson ? "chevron-down" : "chevron-right"} + className="mb-1 h-6 px-0 font-medium"> + {t("json")} + + {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} + router.back()}> + {t("go_back")} + + + + ); + } + + 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 6c8f058146022e..3fb28a58406109 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 1751bc819643e3..602225a4904032 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 processes audit records through `BookingAuditTaskConsumer`, which provides: + +### Core Method -The audit system is accessed through `BookingAuditService`, which provides: +- `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 -### Convenience Methods +### Record Type Mapping -- `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 +- `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 25748704b458cf..02cf6709804238 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 00000000000000..0cfb3b1ae1e3c8 --- /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 00000000000000..5bd4660e1a6c5b --- /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 c1f57dc0c6b0f8..00000000000000 --- 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 00000000000000..162f2e1f10be53 --- /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 00000000000000..2663ce8a967598 --- /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 00000000000000..372824a2b17726 --- /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 00000000000000..add38c9a5559c3 --- /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 00000000000000..6a89ff7f8f44fb --- /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 4a93906a1844c2..78472ceafb4dde 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 00000000000000..e4e8dfec4267be --- /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 00000000000000..caed606bc7b886 --- /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 00000000000000..aac3f555f4d255 --- /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 950ecabc072a78..6ff4eb4ec0fbc0 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 b9e5c0027c6aef..e65fd665628f6f 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 f2718b904d5f9a..a1a067c00b7784 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 e1b7f7bad9777e..41f604a198a5de 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 00000000000000..5568eeb3429d75 --- /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 d57d74262a9e18..00000000000000 --- 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 00000000000000..43c34a63bce254 --- /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 00000000000000..028a111b798749 --- /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 00000000000000..923df9de7644f1 --- /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 00000000000000..8ad0a8b2bdc628 --- /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 00000000000000..2e1aca41feed9c --- /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 b7be1b08569593..d3583f8083ef4f 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 3131efc37c4c57..ac83a0c7950100 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 824b070ec9fed3..ea84e90166dbee 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 9170dc0c869ff3..c3e2c0cbce9064 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 9b1de598fbc04c..2e10e2a5fd28a3 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -35,6 +35,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 ed088059876de8..118cf7211578a4 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 0b96d937e29c07..0f90fea382cebf 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 7a568ef84a9198..9a1578948480ff 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 ae8cef35e61a35..be85420d875179 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 9e34857d4ed87b..1a03803cbd50e9 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 491b696c30b06d..61111032868591 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 76d4a11649ad62..d66d2650da31c9 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 00000000000000..3184ef498ecf00 --- /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 9ae6b79788d895..f60c5e15c18adf 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 a2a56263ffd58e..fea93ffd5b9ef1 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 cc86632a01f062..03bece8a36c375 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 1f7fa378d20065..24fc0da8ef5256 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 00000000000000..87a02ff563240a --- /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 6a6bfb6254b67f..a2f8f0a1281959 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 00000000000000..961d2bc3751686 --- /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 00000000000000..9ed51a0328afc1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZGetAuditLogsInputSchema = z.object({ + bookingUid: z.string(), +}); + +export type TGetAuditLogsInputSchema = z.infer; +
{t("no_audit_logs_found")}
+ {JSON.stringify(log.data, null, 2)} +
{t("error_loading_booking_logs")}
{error.message}