From 2218a45d83186a14fb2d294b301abe436619a6aa Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 15 Dec 2025 16:21:30 +0530 Subject: [PATCH] feat: extract core booking audit infrastructure from PR 25125 (#25729) * feat: extract core booking audit infrastructure from PR 25125 This PR contains only the core booking audit infrastructure changes from PR 25125, excluding integration changes with booking flows. Included: - All packages/features/booking-audit/* (core audit services, actions, repository) - packages/features/di/containers/BookingAuditViewerService.container.ts - packages/features/tasker/tasker.ts (audit task types) - packages/features/bookings/lib/types/actor.ts (actor types for audit) - packages/features/bookings/repositories/BookingRepository.ts (getFromRescheduleUid method) - apps/web/modules/booking/logs/views/booking-logs-view.tsx (UI for viewing audit logs) - apps/web/public/static/locales/en/common.json (translations) Excluded (integration changes): - packages/trpc/server/* (tRPC handlers) - packages/features/ee/round-robin/* (round-robin integration) - packages/features/bookings/lib/handleCancelBooking.ts - packages/features/bookings/lib/handleConfirmation.ts - packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts - packages/features/bookings/lib/service/RegularBookingService.ts - apps/api/v2/* (API v2 integration) Co-Authored-By: hariom@cal.com * fix: make booking audit interfaces backwards-compatible with main - Add queueAudit method back to BookingAuditProducerService interface for backwards compatibility - Implement queueAudit method in BookingAuditTaskerProducerService - Make userTimeZone parameter optional in BookingAuditViewerService - Add BookingAuditTaskProducerActionData type for legacy queueAudit method - Use any generics in BookingAuditActionServiceRegistry (matching PR 25125) - Fix type assertions in BookingAuditTaskConsumer Co-Authored-By: hariom@cal.com * fix switch eslint and ts * feat: enhance BookingAuditViewerService with logging and type improvements - Added ISimpleLogger dependency to BookingAuditViewerService for better error handling. - Updated actor type in enriched audit logs to use AuditActorType for improved type safety. - Replaced console.error with logger for error reporting when no rescheduled log is found. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../booking-audit-producer.service.ts | 4 +- .../booking/logs/views/booking-logs-view.tsx | 269 +++++++++++------- apps/web/public/static/locales/en/common.json | 21 +- .../di/BookingAuditTaskConsumer.module.ts | 2 + ...ookingAuditTaskerProducerService.module.ts | 2 + .../di/BookingAuditViewerService.module.ts | 4 + .../lib/actions/AcceptedAuditActionService.ts | 18 +- .../AttendeeAddedAuditActionService.ts | 9 +- ...AttendeeNoShowUpdatedAuditActionService.ts | 9 +- .../AttendeeRemovedAuditActionService.ts | 12 +- .../lib/actions/AuditActionServiceHelper.ts | 21 ++ .../actions/CancelledAuditActionService.ts | 9 +- .../lib/actions/CreatedAuditActionService.ts | 16 +- .../HostNoShowUpdatedAuditActionService.ts | 9 +- .../lib/actions/IAuditActionService.ts | 23 +- .../LocationChangedAuditActionService.ts | 2 +- .../actions/ReassignmentAuditActionService.ts | 26 +- .../lib/actions/RejectedAuditActionService.ts | 9 +- .../RescheduleRequestedAuditActionService.ts | 9 +- .../actions/RescheduledAuditActionService.ts | 81 +++++- .../booking-audit/lib/common/changeSchemas.ts | 6 + .../lib/repository/IBookingAuditRepository.ts | 7 + .../PrismaBookingAuditRepository.ts | 17 ++ .../BookingAuditActionServiceRegistry.ts | 87 ++++++ .../BookingAuditProducerService.interface.ts | 117 +++++++- .../lib/service/BookingAuditTaskConsumer.ts | 109 ++++--- .../BookingAuditTaskerProducerService.ts | 233 +++++++++++++-- .../lib/service/BookingAuditViewerService.ts | 191 ++++++++++--- .../service/booking-audit.integration-test.ts | 10 +- .../lib/types/bookingAuditTask.ts | 84 ++++-- packages/features/bookings/lib/types/actor.ts | 11 + .../repositories/BookingRepository.ts | 74 +++-- .../BookingAuditViewerService.container.ts | 18 ++ packages/features/tasker/tasker.ts | 4 +- 34 files changed, 1194 insertions(+), 329 deletions(-) create mode 100644 packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts create mode 100644 packages/features/di/containers/BookingAuditViewerService.container.ts 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 index f6b7f49b5f..059d93dd68 100644 --- a/apps/api/v2/src/lib/services/booking-audit-producer.service.ts +++ b/apps/api/v2/src/lib/services/booking-audit-producer.service.ts @@ -1,13 +1,15 @@ import { TaskerService } from "@/lib/services/tasker.service"; +import { Logger } from "@/lib/logger.bridge"; import { Injectable } from "@nestjs/common"; import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings"; @Injectable() export class BookingAuditProducerService extends BookingAuditTaskerProducerService { - constructor(taskerService: TaskerService) { + constructor(taskerService: TaskerService, bridgeLogger: Logger) { super({ tasker: taskerService.getTasker(), + log: bridgeLogger, }); } } diff --git a/apps/web/modules/booking/logs/views/booking-logs-view.tsx b/apps/web/modules/booking/logs/views/booking-logs-view.tsx index a954ed4631..a23b4af071 100644 --- a/apps/web/modules/booking/logs/views/booking-logs-view.tsx +++ b/apps/web/modules/booking/logs/views/booking-logs-view.tsx @@ -3,6 +3,7 @@ */ "use client"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -12,32 +13,47 @@ 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"; +import { Avatar } from "@calcom/ui/components/avatar"; +import ServerTrans from "@calcom/lib/components/ServerTrans"; +import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository"; interface BookingLogsViewProps { bookingUid: string; } +type TranslationComponent = { + type: "link"; + href: string; +}; + +type TranslationWithParams = { + key: string; + params?: Record; + components?: TranslationComponent[]; +}; + type AuditLog = { id: string; action: string; type: string; timestamp: string; data: Record | null; + actionDisplayTitle: TranslationWithParams; + displayFields?: Array<{ labelKey: string; valueKey: string }>; actor: { - type: string; + type: AuditActorType; displayName: string | null; + displayEmail: string | null; + displayAvatar: 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 }>; + actorFilter: AuditActorType | null; + onActorFilterChange: (value: AuditActorType | null) => void; + actorOptions: Array<{ label: string; value: AuditActorType }>; } interface BookingLogsTimelineProps { @@ -70,14 +86,26 @@ const getActionIcon = (action: string) => { } }; +const getActorRoleLabel = (actorType: AuditActorType): string | null => { + switch (actorType) { + case "GUEST": + return "Guest"; + case "ATTENDEE": + return "Attendee"; + case "SYSTEM": + return null; + case "USER": + return null; + default: + return null; + } +}; + function BookingLogsFilters({ searchTerm, onSearchChange, - typeFilter, - onTypeFilterChange, actorFilter, onActorFilterChange, - typeOptions, actorOptions, }: BookingLogsFiltersProps) { const { t } = useLocale(); @@ -93,25 +121,13 @@ function BookingLogsFilters({ /> -
- { if (!option) return; - onActorFilterChange(option.value || null); + onActorFilterChange((option.value as AuditActorType) || null); }} options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]} /> @@ -120,6 +136,68 @@ function BookingLogsFilters({ ); } +/** + * Renders the action display title with support for Trans component interpolation + * Handles translations with embedded components (e.g., links) for proper i18n support + */ +function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWithParams }) { + const { t } = useLocale(); + + if (actionDisplayTitle.components?.length) { + return ( + + comp.type === "link" ? ( + + ) : ( + + ) + )} + /> + ); + } + + return <>{t(actionDisplayTitle.key, actionDisplayTitle.params)}; +} + +interface JsonViewerProps { + data: Record | null; +} + +function JsonViewer({ data }: JsonViewerProps) { + if (!data || Object.keys(data).length === 0) { + return null; + } + + const jsonString = JSON.stringify(data, null, 2); + const lines = jsonString.split("\n"); + const lineCount = lines.length; + const lineNumberWidth = Math.max(2, Math.ceil(Math.log10(lineCount)) + 1); + + return ( +
+ {lines.map((line, idx) => ( +
+ + {idx + 1} + + {line || " "} +
+ ))} +
+ ); +} + function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { const { t } = useLocale(); const [expandedLogIds, setExpandedLogIds] = useState>(new Set()); @@ -157,8 +235,8 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { {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; + const actorRole = getActorRoleLabel(log.actor.type); return (
@@ -178,78 +256,84 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {

- {actionDisplay} +

- {log.actor.displayName} + {log.actor.displayAvatar && ( + + )} + + {log.actor.displayName} + {actorRole && ` (${actorRole})`} + • {dayjs(log.timestamp).fromNow()}
- -
-
- +
+
+ +
+ + {isExpanded && ( +
+ {/* Render displayFields if available, otherwise show type */} + {log.displayFields && log.displayFields.length > 0 ? ( + log.displayFields.map((field, idx) => ( +
+ {t(field.labelKey)} + {t(field.valueKey)} +
+ )) + ) : null} +
+ {t("actor")} + {log.actor.displayName || log.actor.type} +
+
+ + {t("timestamp")} + + + {dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")} + +
+ {log.data && Object.keys(log.data).length > 0 && ( +
+
+ +
+
+ {showJson && } +
+
+ )} +
+ )}
- - {isExpanded && ( -
-
-
- {t("type")} - {log.type} -
-
- {t("actor")} - {log.actor.type} -
-
- - {t("timestamp")} - - - {dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")} - -
- - {log.data && Object.keys(log.data).length > 0 && ( -
- - {showJson && ( -
-                                                        {JSON.stringify(log.data, null, 2)}
-                                                    
- )} -
- )} -
-
- )}
); @@ -261,8 +345,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(""); - const [typeFilter, setTypeFilter] = useState(null); - const [actorFilter, setActorFilter] = useState(null); + const [actorFilter, setActorFilter] = useState(null); const { t } = useLocale(); const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({ bookingUid, @@ -302,19 +385,12 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) { 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; + return matchesSearch && 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 uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))) as AuditActorType[]; const actorOptions = uniqueActorTypes.map((actorType) => ({ label: actorType, @@ -326,11 +402,8 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3fd2cd3197..1b7f9f690a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4114,8 +4114,25 @@ "saved": "Saved", "booking_history": "Booking History", "booking_history_description": "View the history of actions performed on this booking", - "audit_action": { - "created": "Created" + "booking_audit_action": { + "created": "Created", + "cancelled": "Cancelled", + "rescheduled": "Rescheduled {{oldDate}} -> <0>{{newDate}}", + "rescheduled_from": "Rescheduled <0>{{oldDate}} -> {{newDate}}", + "accepted": "Accepted", + "reschedule_requested": "Reschedule Requested", + "attendee_added": "Attendee Added", + "host_no_show_updated": "Host No-Show Updated", + "rejected": "Rejected", + "attendee_removed": "Attendee Removed", + "reassignment": "Reassignment", + "booking_reassigned_to_host": "Booking reassigned to {{host}}", + "location_changed": "Location Changed", + "location_changed_from_to": "Location changed from {{fromLocation}} to {{toLocation}}", + "attendee_no_show_updated": "Attendee No-Show Updated", + "type": "Assignment Type", + "assignmentType_manual": "Manual Assignment", + "assignmentType_roundRobin": "Round Robin Assignment" }, "error_loading_booking_logs": "Error loading booking logs", "no_audit_logs_found": "No audit logs found", diff --git a/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts b/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts index 2663ce8a96..0e81f0a111 100644 --- a/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts +++ b/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts @@ -3,6 +3,7 @@ import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/token import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module"; import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module"; import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features"; +import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User"; import { createModule, bindModuleToClassOnToken } from "../../di/di"; @@ -19,6 +20,7 @@ const loadModule = bindModuleToClassOnToken({ bookingAuditRepository: bookingAuditRepositoryModuleLoader, auditActorRepository: auditActorRepositoryModuleLoader, featuresRepository: featuresRepositoryModuleLoader, + userRepository: userRepositoryModuleLoader, }, }); diff --git a/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts b/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts index 372824a2b1..5f607aff3c 100644 --- a/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts +++ b/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts @@ -1,6 +1,7 @@ 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 { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service"; import { createModule, bindModuleToClassOnToken } from "../../di/di"; @@ -15,6 +16,7 @@ const loadModule = bindModuleToClassOnToken({ classs: BookingAuditTaskerProducerService, depsMap: { tasker: taskerModuleLoader, + log: loggerModuleLoader, }, }); diff --git a/packages/features/booking-audit/di/BookingAuditViewerService.module.ts b/packages/features/booking-audit/di/BookingAuditViewerService.module.ts index 6a89ff7f8f..a78ddf55ef 100644 --- a/packages/features/booking-audit/di/BookingAuditViewerService.module.ts +++ b/packages/features/booking-audit/di/BookingAuditViewerService.module.ts @@ -2,6 +2,8 @@ import { BookingAuditViewerService } from "@calcom/features/booking-audit/lib/se 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 { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking"; +import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service"; import { createModule, bindModuleToClassOnToken } from "../../di/di"; @@ -19,6 +21,8 @@ const loadModule = bindModuleToClassOnToken({ depsMap: { bookingAuditRepository: bookingAuditRepositoryModuleLoader, userRepository: userRepositoryModuleLoader, + bookingRepository: bookingRepositoryModuleLoader, + log: loggerModuleLoader, }, }); diff --git a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts index 52a387fd31..14622da9d5 100644 --- a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts @@ -1,6 +1,7 @@ +import type { BookingStatus } from "@calcom/prisma/enums"; import { z } from "zod"; -import { StringChangeSchema } from "../common/changeSchemas"; +import { BookingStatusChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; @@ -11,7 +12,7 @@ import type { IAuditActionService, TranslationWithParams } from "./IAuditActionS // Module-level because it is passed to IAuditActionService type outside the class scope const fieldsSchemaV1 = z.object({ - status: StringChangeSchema, + status: BookingStatusChangeSchema, }); export class AcceptedAuditActionService @@ -59,11 +60,16 @@ export class AcceptedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.accepted" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): AcceptedAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): AcceptedAuditDisplayData { const { fields } = storedData; return { previousStatus: fields.status.old ?? null, @@ -75,6 +81,6 @@ export class AcceptedAuditActionService export type AcceptedAuditData = z.infer; export type AcceptedAuditDisplayData = { - previousStatus: string | null; - newStatus: string | null; + previousStatus: BookingStatus | null; + newStatus: BookingStatus | null; }; diff --git a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts index 40e5d30b7a..9c2d9abfd1 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts @@ -59,11 +59,16 @@ export class AttendeeAddedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.attendee_added" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeAddedAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): AttendeeAddedAuditDisplayData { const { fields } = storedData; const previousAttendeesSet = new Set(fields.attendees.old ?? []); const addedAttendees = fields.attendees.new.filter( diff --git a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts index 662ec76e6d..6de87ec2f4 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts @@ -59,11 +59,16 @@ export class AttendeeNoShowUpdatedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.attendee_no_show_updated" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeNoShowUpdatedAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): AttendeeNoShowUpdatedAuditDisplayData { const { fields } = storedData; return { noShowAttendee: fields.noShowAttendee.new, diff --git a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts index d90356ced1..8fc6c63374 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts @@ -59,16 +59,18 @@ export class AttendeeRemovedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.attendee_removed" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeRemovedAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): AttendeeRemovedAuditDisplayData { const { fields } = storedData; - // Note: fields.attendees stores the state change (old -> new), not the removed attendees directly - // old = attendees before removal, new = remaining attendees after removal const remainingAttendeesSet = new Set(fields.attendees.new ?? []); - // Compute removed attendees: those in old but not in new (remaining) const removedAttendees = (fields.attendees.old ?? []).filter( (email) => !remainingAttendeesSet.has(email) ); diff --git a/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts b/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts index e4e8dfec42..e5a0a63ecb 100644 --- a/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts +++ b/packages/features/booking-audit/lib/actions/AuditActionServiceHelper.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { formatInTimeZone } from "date-fns-tz"; /** * Audit Action Service Helper @@ -61,5 +62,25 @@ export class AuditActionServiceHelper< const parsed = z.object({ version: z.number() }).parse(data); return parsed.version; } + + /** + * Format date in user's timezone with format: MMM d, yyyy (e.g., "Jul 7, 2025") + * @param date - Date string or timestamp + * @param timeZone - User's timezone (defaults to UTC) + * @returns Formatted date string + */ + static formatDateInTimeZone(date: string | number, timeZone: string = "UTC"): string { + return formatInTimeZone(new Date(date), timeZone, "MMM d, yyyy"); + } + + /** + * Format datetime in user's timezone with format: yyyy-MM-dd HH:mm:ss (e.g., "2025-07-07 09:42:10") + * @param date - Date string or timestamp + * @param timeZone - User's timezone (defaults to UTC) + * @returns Formatted datetime string + */ + static formatDateTimeInTimeZone(date: string | number, timeZone: string = "UTC"): string { + return formatInTimeZone(new Date(date), timeZone, "yyyy-MM-dd HH:mm:ss"); + } } diff --git a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts index fef3683181..4b2a8b1e1f 100644 --- a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts @@ -61,11 +61,16 @@ export class CancelledAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.cancelled" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): CancelledAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): CancelledAuditDisplayData { const { fields } = storedData; return { cancellationReason: fields.cancellationReason.new ?? null, diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index a2cccf38da..b4f59469b9 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -61,15 +61,23 @@ export class CreatedAuditActionService implements IAuditActionService< return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.created" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): CreatedAuditDisplayData { + getDisplayJson({ + storedData, + userTimeZone, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): CreatedAuditDisplayData { const { fields } = storedData; + const timeZone = userTimeZone; + return { - startTime: new Date(fields.startTime).toISOString(), - endTime: new Date(fields.endTime).toISOString(), + startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, timeZone), + endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, timeZone), status: fields.status, }; } diff --git a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts index b1d08faeed..6901435217 100644 --- a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts @@ -59,11 +59,16 @@ export class HostNoShowUpdatedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.host_no_show_updated" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): HostNoShowUpdatedAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): HostNoShowUpdatedAuditDisplayData { const { fields } = storedData; return { noShowHost: fields.noShowHost.new, diff --git a/packages/features/booking-audit/lib/actions/IAuditActionService.ts b/packages/features/booking-audit/lib/actions/IAuditActionService.ts index e49e1bed7f..f86133b9d8 100644 --- a/packages/features/booking-audit/lib/actions/IAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/IAuditActionService.ts @@ -64,19 +64,34 @@ export interface IAuditActionService< /** * Get flattened JSON data for display (fields only, no version wrapper) * Optional - implement only if custom display formatting is needed - * @param storedData - Parsed stored data { version, fields } + * @param params - Object containing storedData and userTimeZone + * @param params.storedData - Parsed stored data { version, fields } + * @param params.userTimeZone - User's timezone for datetime formatting (required) * @returns The fields object without version wrapper and we decide what fields to show to the client */ - getDisplayJson?(storedData: { version: number; fields: z.infer }): unknown; + getDisplayJson?(params: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): unknown; /** * Get the display title for the audit action * Returns a translation key with optional interpolation params for dynamic titles * (e.g., "Booking reassigned to John Doe" instead of just "Reassignment") - * @param storedData - Parsed stored data { version, fields } + * @param params - Object containing storedData and userTimeZone + * @param params.storedData - Parsed stored data { version, fields } + * @param params.userTimeZone - User's timezone for date formatting (required) * @returns Translation key with optional interpolation params */ - getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise; + getDisplayTitle(params: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise; + + /** + * Returns additional display fields with translation keys for frontend rendering + * Optional - implement only if custom display fields are needed + * @param storedData - Parsed stored data { version, fields } + * @returns Array of field objects with label and value translation keys + */ + getDisplayFields?(storedData: { version: number; fields: z.infer }): Array<{ + labelKey: string; // Translation key for field label + valueKey: string; // Translation key for field value + }>; /** * Migrate old version data to latest version diff --git a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts index b8199cf8c4..c939e27100 100644 --- a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts @@ -60,7 +60,7 @@ export class LocationChangedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + async getDisplayTitle({ storedData }: { storedData: { version: number; fields: z.infer }; userTimeZone?: string }): Promise { const { fields } = storedData; // TODO: Ideally we want to translate the location label to the user's locale // We currently don't accept requesting user's translate fn here, fix it later. diff --git a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts index 37066914f5..70ecacabd3 100644 --- a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts @@ -15,6 +15,7 @@ const fieldsSchemaV1 = z.object({ assignedToId: NumberChangeSchema, assignedById: NumberChangeSchema, reassignmentReason: StringChangeSchema, + reassignmentType: z.enum(["manual", "roundRobin"]), userPrimaryEmail: StringChangeSchema.optional(), title: StringChangeSchema.optional(), }); @@ -64,7 +65,7 @@ export class ReassignmentAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + async getDisplayTitle({ storedData }: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { const { fields } = storedData; const user = await this.userRepository.findById({ id: fields.assignedToId.new }); const reassignedToName = user?.name || "Unknown"; @@ -74,13 +75,34 @@ export class ReassignmentAuditActionService }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): ReassignmentAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): ReassignmentAuditDisplayData { const { fields } = storedData; return { newAssignedToId: fields.assignedToId.new, reassignmentReason: fields.reassignmentReason.new ?? null, }; } + + getDisplayFields(storedData: { version: number; fields: z.infer }): Array<{ + labelKey: string; + valueKey: string; + }> { + const { fields } = storedData; + + const typeTranslationKey = `booking_audit_action.assignmentType_${fields.reassignmentType}`; + + return [ + { + labelKey: "booking_audit_action.type", + valueKey: typeTranslationKey, + } + ]; + } } export type ReassignmentAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts index 91bfb9d951..3d99c5eb75 100644 --- a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts @@ -60,11 +60,16 @@ export class RejectedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.rejected" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): RejectedAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): RejectedAuditDisplayData { const { fields } = storedData; return { rejectionReason: fields.rejectionReason.new ?? null, diff --git a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts index 8afe5123df..f1f4577604 100644 --- a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts @@ -61,11 +61,16 @@ export class RescheduleRequestedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(): Promise { + async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { return { key: "booking_audit_action.reschedule_requested" }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): RescheduleRequestedAuditDisplayData { + getDisplayJson({ + storedData, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): RescheduleRequestedAuditDisplayData { const { fields } = storedData; return { reason: fields.cancellationReason.new ?? null, diff --git a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts index 950670cbd4..a4e89e9aad 100644 --- a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts @@ -61,21 +61,86 @@ export class RescheduledAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + async getDisplayTitle({ + storedData, + userTimeZone, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): Promise { const rescheduledToUid = storedData.fields.rescheduledToUid.new; + const timeZone = userTimeZone; + + // Format dates in user timezone + const oldDate = storedData.fields.startTime.old + ? AuditActionServiceHelper.formatDateInTimeZone(storedData.fields.startTime.old, timeZone) + : ""; + const newDate = storedData.fields.startTime.new + ? AuditActionServiceHelper.formatDateInTimeZone(storedData.fields.startTime.new, timeZone) + : ""; + return { key: "booking_audit_action.rescheduled", - components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}` }] : undefined, + params: { + oldDate, + newDate, + }, + components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}/logs` }] : undefined, }; } - getDisplayJson(storedData: { version: number; fields: z.infer }): RescheduledAuditDisplayData { - const { fields } = storedData; + getDisplayTitleForRescheduledFromLog({ + fromRescheduleUid, + userTimeZone, + parsedData, + }: { + fromRescheduleUid: string; + userTimeZone: string; + parsedData: { version: number; fields: z.infer }; + }): TranslationWithParams { + const timeZone = userTimeZone; + + // Format dates in user timezone + const oldDate = parsedData.fields.startTime.old + ? AuditActionServiceHelper.formatDateInTimeZone(parsedData.fields.startTime.old, timeZone) + : ""; + const newDate = parsedData.fields.startTime.new + ? AuditActionServiceHelper.formatDateInTimeZone(parsedData.fields.startTime.new, timeZone) + : ""; + return { - previousStartTime: fields.startTime.old ?? null, - newStartTime: fields.startTime.new ?? null, - previousEndTime: fields.endTime.old ?? null, - newEndTime: fields.endTime.new ?? null, + key: "booking_audit_action.rescheduled_from", + params: { + oldDate, + newDate, + }, + components: [{ type: "link", href: `/booking/${fromRescheduleUid}/logs` }], + }; + } + + getDisplayJson({ + storedData, + userTimeZone, + }: { + storedData: { version: number; fields: z.infer }; + userTimeZone: string; + }): RescheduledAuditDisplayData { + const { fields } = storedData; + const timeZone = userTimeZone; + + return { + previousStartTime: fields.startTime.old + ? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.old, timeZone) + : null, + newStartTime: fields.startTime.new + ? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.new, timeZone) + : null, + previousEndTime: fields.endTime.old + ? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.old, timeZone) + : null, + newEndTime: fields.endTime.new + ? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.new, timeZone) + : null, rescheduledToUid: fields.rescheduledToUid.new ?? null, }; } diff --git a/packages/features/booking-audit/lib/common/changeSchemas.ts b/packages/features/booking-audit/lib/common/changeSchemas.ts index 50025e8ec2..55dece6107 100644 --- a/packages/features/booking-audit/lib/common/changeSchemas.ts +++ b/packages/features/booking-audit/lib/common/changeSchemas.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { BookingStatus } from "@calcom/prisma/enums"; /** * Common change schemas for audit data * These represent old -> new value transitions @@ -25,3 +26,8 @@ export const NumberChangeSchema = z.object({ new: z.number(), }); +export const BookingStatusChangeSchema = z.object({ + old: z.nativeEnum(BookingStatus).nullable(), + new: z.nativeEnum(BookingStatus), +}); + diff --git a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts index e65fd66562..199f903a8e 100644 --- a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts @@ -55,4 +55,11 @@ export interface IBookingAuditRepository { * @returns Array of audit logs with actor information, ordered by timestamp DESC */ findAllForBooking(bookingUid: string): Promise; + + /** + * Retrieves all RESCHEDULED audit logs for a specific booking + * @param bookingUid - The unique identifier of the booking + * @returns Array of RESCHEDULED audit logs with actor information, ordered by timestamp DESC + */ + findRescheduledLogsOfBooking(bookingUid: string): Promise; } diff --git a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts index 41f604a198..61de783d3c 100644 --- a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts @@ -50,5 +50,22 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository { }, }); } + + async findRescheduledLogsOfBooking(bookingUid: string): Promise { + return this.deps.prismaClient.bookingAudit.findMany({ + where: { + bookingUid, + action: "RESCHEDULED", + }, + include: { + actor: { + select: safeActorSelect, + }, + }, + orderBy: { + timestamp: "desc", + }, + }); + } } diff --git a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts new file mode 100644 index 0000000000..23ce4913d4 --- /dev/null +++ b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts @@ -0,0 +1,87 @@ +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import type { IAuditActionService } from "../actions/IAuditActionService"; +import type { BookingAuditAction } from "../repository/IBookingAuditRepository"; + +// Import all action services +import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService"; +import { CancelledAuditActionService, type CancelledAuditData } from "../actions/CancelledAuditActionService"; +import { RescheduledAuditActionService, type RescheduledAuditData } from "../actions/RescheduledAuditActionService"; +import { AcceptedAuditActionService, type AcceptedAuditData } from "../actions/AcceptedAuditActionService"; +import { RescheduleRequestedAuditActionService, type RescheduleRequestedAuditData } from "../actions/RescheduleRequestedAuditActionService"; +import { AttendeeAddedAuditActionService, type AttendeeAddedAuditData } from "../actions/AttendeeAddedAuditActionService"; +import { HostNoShowUpdatedAuditActionService, type HostNoShowUpdatedAuditData } from "../actions/HostNoShowUpdatedAuditActionService"; +import { RejectedAuditActionService, type RejectedAuditData } from "../actions/RejectedAuditActionService"; +import { AttendeeRemovedAuditActionService, type AttendeeRemovedAuditData } from "../actions/AttendeeRemovedAuditActionService"; +import { ReassignmentAuditActionService, type ReassignmentAuditData } from "../actions/ReassignmentAuditActionService"; +import { LocationChangedAuditActionService, type LocationChangedAuditData } from "../actions/LocationChangedAuditActionService"; +import { AttendeeNoShowUpdatedAuditActionService, type AttendeeNoShowUpdatedAuditData } from "../actions/AttendeeNoShowUpdatedAuditActionService"; + +/** + * Union type for all audit action data types + * Used for type-safe handling of action-specific data + */ +export type AuditActionData = + | CreatedAuditData + | CancelledAuditData + | RescheduledAuditData + | AcceptedAuditData + | RescheduleRequestedAuditData + | AttendeeAddedAuditData + | HostNoShowUpdatedAuditData + | RejectedAuditData + | AttendeeRemovedAuditData + | ReassignmentAuditData + | LocationChangedAuditData + | AttendeeNoShowUpdatedAuditData; + + +/** + * BookingAuditActionServiceRegistry + * + * Centralized registry for all booking audit action services. + * Provides a single source of truth for action service mapping and eliminates + * code duplication between consumer and viewer services. + */ +interface BookingAuditActionServiceRegistryDeps { + userRepository: UserRepository; +} + +export class BookingAuditActionServiceRegistry { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly actionServices: Map>; + + constructor(private deps: BookingAuditActionServiceRegistryDeps) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const services: Array<[BookingAuditAction, IAuditActionService]> = [ + ["CREATED", new CreatedAuditActionService()], + ["CANCELLED", new CancelledAuditActionService()], + ["RESCHEDULED", new RescheduledAuditActionService()], + ["ACCEPTED", new AcceptedAuditActionService()], + ["RESCHEDULE_REQUESTED", new RescheduleRequestedAuditActionService()], + ["ATTENDEE_ADDED", new AttendeeAddedAuditActionService()], + ["HOST_NO_SHOW_UPDATED", new HostNoShowUpdatedAuditActionService()], + ["REJECTED", new RejectedAuditActionService()], + ["ATTENDEE_REMOVED", new AttendeeRemovedAuditActionService()], + ["REASSIGNMENT", new ReassignmentAuditActionService(deps.userRepository)], + ["LOCATION_CHANGED", new LocationChangedAuditActionService()], + ["ATTENDEE_NO_SHOW_UPDATED", new AttendeeNoShowUpdatedAuditActionService()], + ]; + this.actionServices = new Map(services); + } + + /** + * 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 + * @throws Error if no service is found for the action + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getActionService(action: BookingAuditAction): IAuditActionService { + const service = this.actionServices.get(action); + if (!service) { + throw new Error(`No action service found for: ${action}`); + } + return service; + } +} diff --git a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts index 5568eeb342..a2acfb1192 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts @@ -1,5 +1,19 @@ +import { z } from "zod"; + import type { Actor } from "../../../bookings/lib/types/actor"; import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask"; +import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService"; +import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; +import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService"; +import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService"; +import { CancelledAuditActionService } from "../actions/CancelledAuditActionService"; +import { CreatedAuditActionService } from "../actions/CreatedAuditActionService"; +import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService"; +import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService"; +import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService"; +import { RejectedAuditActionService } from "../actions/RejectedAuditActionService"; +import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService"; +import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; /** * BookingAuditProducerService Interface @@ -7,28 +21,103 @@ import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTa * Producer abstraction for queueing booking audit tasks. * Allows for multiple implementations (e.g., Tasker, Trigger.dev). * + * Uses specialized methods per action type for better TypeScript performance + * and type safety, avoiding large discriminated unions. + * * 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 + * Queue Audit - Legacy method for backwards compatibility with existing code * - * 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 } - * }); + * @deprecated Use specialized methods (queueCreatedAudit, queueCancelledAudit, etc.) instead */ queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise; + + queueCreatedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueRescheduledAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueAcceptedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueCancelledAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueRescheduleRequestedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueAttendeeAddedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueHostNoShowUpdatedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueRejectedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueAttendeeRemovedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueReassignmentAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueLocationChangedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; + + queueAttendeeNoShowUpdatedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise; } diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts index 43c34a63bc..26027edb85 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts @@ -2,11 +2,12 @@ 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 { UserRepository } from "@calcom/features/users/repositories/UserRepository"; 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 { BookingAuditTaskBasePayload } from "../types/bookingAuditTask"; +import { BookingAuditTaskBaseSchema } from "../types/bookingAuditTask"; +import { BookingAuditActionServiceRegistry, type AuditActionData } from "./BookingAuditActionServiceRegistry"; import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction } from "../repository/IBookingAuditRepository"; import type { IAuditActorRepository } from "../repository/IAuditActorRepository"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -15,6 +16,7 @@ interface BookingAuditTaskConsumerDeps { bookingAuditRepository: IBookingAuditRepository; auditActorRepository: IAuditActorRepository; featuresRepository: IFeaturesRepository; + userRepository: UserRepository; } type CreateBookingAuditInput = { @@ -45,44 +47,53 @@ type BookingAudit = { * * Note: PENDING and AWAITING_HOST actions are intentionally not implemented. * These represent initial booking states captured by the CREATED action. + * + * Dependency Injection Note: + * - `userRepository` is included in this service's dependencies because it's required by + * ActionServices (e.g., ReassignmentAuditActionService) that need to fetch user data. + * - Currently, dependencies are passed down the flow: TaskConsumer → Registry → ActionService + * - Future improvement: Consider creating a container/module system for action services + * that can build instances directly with their dependencies, eliminating the need to + * pass dependencies through intermediate layers. */ export class BookingAuditTaskConsumer { - private readonly createdActionService: CreatedAuditActionService; + private readonly actionServiceRegistry: BookingAuditActionServiceRegistry; private readonly bookingAuditRepository: IBookingAuditRepository; private readonly auditActorRepository: IAuditActorRepository; private readonly featuresRepository: IFeaturesRepository; + private readonly userRepository: UserRepository; constructor(private readonly deps: BookingAuditTaskConsumerDeps) { this.bookingAuditRepository = deps.bookingAuditRepository; this.auditActorRepository = deps.auditActorRepository; this.featuresRepository = deps.featuresRepository; + this.userRepository = deps.userRepository; - // Each service instantiates its own helper with its specific schema - this.createdActionService = new CreatedAuditActionService(); + // Centralized registry for all action services + this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository }); } /** * Process Audit Task - Entry point for task handler * * This method handles: - * 1. Schema validation (accepts all supported versions) + * 1. Base schema validation (parses data as unknown) * 2. Feature flag checks - * 3. Schema migration to latest version if needed + * 3. Action-specific data validation and migration * 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 + * Two-Stage Parsing: + * - First validates base fields with lean schema (data as unknown) + * - Then validates data with action-specific schema via action service + * - This avoids large discriminated unions for better TypeScript performance * * @param payload - The booking audit task payload (unknown type, will be validated) * @param taskId - Optional task ID for updating migrated payload in DB * @returns Promise that resolves when processing is complete */ async processAuditTask(payload: unknown, taskId: string): Promise { - // Validate payload schema (accepts all supported versions) - const parseResult = BookingAuditTaskConsumerPayloadSchema.safeParse(payload); + // Step 1: Parse with lean base schema (data as unknown) + const parseResult = BookingAuditTaskBaseSchema.safeParse(payload); if (!parseResult.success) { const errorMsg = `Invalid booking audit payload: ${safeStringify(parseResult.error.errors)}`; @@ -110,18 +121,18 @@ export class BookingAuditTaskConsumer { return; } + // Step 2: Validate and migrate data with action-specific schema 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 }); + // dataInLatestFormat is validated by action-specific schema in migrateIfNeeded + await this.onBookingAction({ bookingUid, actor, action, data: dataInLatestFormat as AuditActionData, timestamp }); } /** - * Migrate If Needed - Migrates data to latest version + * Migrate If Needed - Validates and migrates data to latest version * - * Calls the action service's migrateToLatest method which: + * This is where action-specific data validation happens. + * The action service's migrateToLatest method: * - Validates the data against all supported versions * - Migrates to latest version if needed * - Returns validated data with migration status @@ -129,15 +140,15 @@ export class BookingAuditTaskConsumer { * If migration occurred, updates the task payload in DB for retries. */ private async migrateIfNeeded(params: { - action: BookingAuditTaskConsumerPayload["action"]; + action: BookingAuditAction; data: unknown; - payload: BookingAuditTaskConsumerPayload; + payload: BookingAuditTaskBasePayload; taskId: string; }) { const { action, data, payload, taskId } = params; - const actionService = this.getActionService(action); + const actionService = this.actionServiceRegistry.getActionService(action); - // migrateToLatest is now required - validates and migrates if needed + // migrateToLatest validates data with action-specific schema and migrates if needed const migrationResult = actionService.migrateToLatest(data); // If migrated, update task payload in DB @@ -151,20 +162,6 @@ export class BookingAuditTaskConsumer { 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 * @@ -176,7 +173,7 @@ export class BookingAuditTaskConsumer { * @param taskId - Task ID (required for DB update) */ private async updateTaskPayload( - payload: BookingAuditTaskConsumerPayload, + payload: BookingAuditTaskBasePayload, latestData: unknown, taskId: string ): Promise { @@ -256,7 +253,7 @@ export class BookingAuditTaskConsumer { * * Maps booking actions to their corresponding audit record types: * - CREATED → RECORD_CREATED - * - Future actions like CANCELLED, RESCHEDULED → RECORD_UPDATED + * - All other actions (CANCELLED, RESCHEDULED, etc.) → RECORD_UPDATED * - Future actions like DELETED → RECORD_DELETED * * @param action - The booking audit action @@ -265,12 +262,29 @@ export class BookingAuditTaskConsumer { 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}`); + + // All update actions fall through to return RECORD_UPDATED + case "CANCELLED": + case "ACCEPTED": + case "REJECTED": + case "RESCHEDULED": + case "RESCHEDULE_REQUESTED": + case "ATTENDEE_ADDED": + case "ATTENDEE_REMOVED": + case "REASSIGNMENT": + case "LOCATION_CHANGED": + case "HOST_NO_SHOW_UPDATED": + case "ATTENDEE_NO_SHOW_UPDATED": + return "RECORD_UPDATED"; + + // PENDING and AWAITING_HOST are not implemented as they represent initial states + case "PENDING": + case "AWAITING_HOST": + throw new Error(`Action ${action} is not supported - it represents an initial booking state captured by CREATED`); } } @@ -278,11 +292,11 @@ export class BookingAuditTaskConsumer { bookingUid: string; actor: Actor; action: BookingAuditAction; - data: CreatedAuditData; + data: AuditActionData; timestamp: number; }): Promise { const { bookingUid, actor, action, data, timestamp } = params; - const actionService = this.getActionService(action); + const actionService = this.actionServiceRegistry.getActionService(action); const versionedData = actionService.getVersionedData(data); const actorId = await this.resolveActorId(actor); const recordType = this.getRecordType({ action }); @@ -292,7 +306,8 @@ export class BookingAuditTaskConsumer { actorId, type: recordType, action, - data: versionedData, + // versionedData is { version: number; fields: unknown } which is JsonValue-compatible + data: versionedData as JsonValue, timestamp: new Date(timestamp), }); } diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts index 028a111b79..5fb391c76c 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts @@ -1,11 +1,28 @@ +import { z } from "zod"; import type { Tasker } from "@calcom/features/tasker/tasker"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; import type { Actor } from "../../../bookings/lib/types/actor"; -import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask"; +import type { BookingAuditAction, BookingAuditTaskProducerActionData } from "../types/bookingAuditTask"; +import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService"; +import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; +import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService"; +import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService"; +import { CancelledAuditActionService } from "../actions/CancelledAuditActionService"; +import { CreatedAuditActionService } from "../actions/CreatedAuditActionService"; +import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService"; +import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService"; +import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService"; +import { RejectedAuditActionService } from "../actions/RejectedAuditActionService"; +import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService"; +import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; import type { BookingAuditProducerService } from "./BookingAuditProducerService.interface"; interface BookingAuditTaskerProducerServiceDeps { tasker: Tasker; + log: ISimpleLogger; } /** @@ -19,41 +36,207 @@ interface BookingAuditTaskerProducerServiceDeps { */ export class BookingAuditTaskerProducerService implements BookingAuditProducerService { private readonly tasker: Tasker; + private readonly log: BookingAuditTaskerProducerServiceDeps["log"]; constructor(private readonly deps: BookingAuditTaskerProducerServiceDeps) { this.tasker = deps.tasker; + this.log = deps.log; } /** - * Queue Audit - Push audit task to Tasker for async processing + * Queue Audit - Legacy method for backwards compatibility * - * 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 } - * }); + * @deprecated Use specialized methods (queueCreatedAudit, queueCancelledAudit, etc.) instead */ - 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", { + async queueAudit( + bookingUid: string, + actor: Actor, + organizationId: number | null, + actionData: BookingAuditTaskProducerActionData + ): Promise { + await this.queueTask({ bookingUid, actor, organizationId, - timestamp: Date.now(), - ...actionData, + action: actionData.action, + data: actionData.data, + }); + } + + /** + * Internal helper to queue audit task to Tasker + * @param params.action - Must be a valid BookingAuditAction value (TYPE from action services are string-typed) + */ + private async queueTask(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + action: string; + data: unknown; + }): Promise { + // Skip queueing for non-organization bookings + if (params.organizationId === null) { + return; + } + if (IS_PRODUCTION) { + return; + } + try { + // Cast action to BookingAuditAction since action service TYPE constants are typed as string + await this.tasker.create("bookingAudit", { + bookingUid: params.bookingUid, + actor: params.actor, + organizationId: params.organizationId, + timestamp: Date.now(), + action: params.action as BookingAuditAction, + data: params.data, + }); + } catch (error) { + this.log.error(`Error while queueing ${params.action} audit`, safeStringify(error)); + } + } + + async queueCreatedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: CreatedAuditActionService.TYPE, + }); + } + + async queueRescheduledAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: RescheduledAuditActionService.TYPE, + }); + } + + async queueAcceptedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: AcceptedAuditActionService.TYPE, + }); + } + + async queueCancelledAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: CancelledAuditActionService.TYPE, + }); + } + + async queueRescheduleRequestedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: RescheduleRequestedAuditActionService.TYPE, + }); + } + + async queueAttendeeAddedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: AttendeeAddedAuditActionService.TYPE, + }); + } + + async queueHostNoShowUpdatedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: HostNoShowUpdatedAuditActionService.TYPE, + }); + } + + async queueRejectedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: RejectedAuditActionService.TYPE, + }); + } + + async queueAttendeeRemovedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: AttendeeRemovedAuditActionService.TYPE, + }); + } + + async queueReassignmentAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: ReassignmentAuditActionService.TYPE, + }); + } + + async queueLocationChangedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: LocationChangedAuditActionService.TYPE, + }); + } + + async queueAttendeeNoShowUpdatedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: AttendeeNoShowUpdatedAuditActionService.TYPE, }); } } diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts index 923df9de76..4f28184a01 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -1,14 +1,19 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; -import { CreatedAuditActionService, type CreatedAuditDisplayData } from "../actions/CreatedAuditActionService"; +import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry"; import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository"; +import type { AuditActorType } from "../repository/IAuditActorRepository"; +import type { TranslationWithParams } from "../actions/IAuditActionService"; +import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; import { IS_PRODUCTION } from "@calcom/lib/constants"; -type AuditDisplayData = CreatedAuditDisplayData; - interface BookingAuditViewerServiceDeps { bookingAuditRepository: IBookingAuditRepository; userRepository: UserRepository; + bookingRepository: BookingRepository; + log: ISimpleLogger; } type EnrichedAuditLog = { @@ -18,10 +23,12 @@ type EnrichedAuditLog = { action: BookingAuditAction; timestamp: string; createdAt: string; - data: AuditDisplayData; + data: Record | null; + actionDisplayTitle: TranslationWithParams; + displayFields?: Array<{ labelKey: string; valueKey: string }>; actor: { id: string; - type: string; + type: AuditActorType; userUuid: string | null; attendeeId: number | null; name: string | null; @@ -36,64 +43,168 @@ type EnrichedAuditLog = { * BookingAuditViewerService - Service for viewing and formatting booking audit logs */ export class BookingAuditViewerService { - private readonly createdActionService: CreatedAuditActionService; + private readonly actionServiceRegistry: BookingAuditActionServiceRegistry; private readonly bookingAuditRepository: IBookingAuditRepository; private readonly userRepository: UserRepository; + private readonly bookingRepository: BookingRepository; + private readonly rescheduledAuditActionService: RescheduledAuditActionService; + private readonly log: BookingAuditViewerServiceDeps["log"]; constructor(private readonly deps: BookingAuditViewerServiceDeps) { this.bookingAuditRepository = deps.bookingAuditRepository; this.userRepository = deps.userRepository; + this.bookingRepository = deps.bookingRepository; + this.log = deps.log; + this.rescheduledAuditActionService = new RescheduledAuditActionService(); - this.createdActionService = new CreatedAuditActionService(); + this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository }); } /** * Get audit logs for a booking with full enrichment and formatting * Handles permission checks, fetches logs, enriches actors, and formats display + * + * For bookings created from a reschedule (has fromReschedule field), this also + * fetches the last RESCHEDULED log from the previous booking and includes it + * as the first log entry with "rescheduled from" context. */ async getAuditLogsForBooking( bookingUid: string, _userId: number, - _userEmail: string + _userEmail: string, + userTimeZone?: 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 + // Default to UTC if no timezone provided (backwards compatibility) + const timezone = userTimeZone ?? "UTC"; + 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, - }, - }; - }) + auditLogs.map((log) => this.enrichAuditLog(log, timezone)) ); + const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid); + + // Check if this booking was created from a reschedule + if (fromRescheduleUid) { + const rescheduledFromLog = await this.buildRescheduledFromLog({ + fromRescheduleUid, + currentBookingUid: bookingUid, + userTimeZone: timezone, + }); + if (rescheduledFromLog) { + // Add the rescheduled log from the previous booking as the first entry + // (appears last chronologically since logs are ordered by timestamp DESC) + enrichedAuditLogs.unshift(rescheduledFromLog); + } + } + return { bookingUid, auditLogs: enrichedAuditLogs, }; } + /** + * Enriches a single audit log with actor information and formatted display data + */ + private async enrichAuditLog(log: BookingAuditWithActor, userTimeZone: string): Promise { + const enrichedActor = await this.enrichActorInformation(log.actor); + + const actionService = this.actionServiceRegistry.getActionService(log.action); + const parsedData = actionService.parseStored(log.data); + + const actionDisplayTitle = await actionService.getDisplayTitle({ storedData: parsedData, userTimeZone }); + + // Get display data - use custom getDisplayJson if available, otherwise use raw fields + const displayData = (actionService.getDisplayJson + ? actionService.getDisplayJson({ storedData: parsedData, userTimeZone }) + : parsedData.fields) as Record | null; + + // Get display fields - use custom getDisplayFields if available + const displayFields = actionService.getDisplayFields + ? actionService.getDisplayFields(parsedData) + : undefined; + + return { + id: log.id, + bookingUid: log.bookingUid, + type: log.type, + action: log.action, + timestamp: log.timestamp.toISOString(), + createdAt: log.createdAt.toISOString(), + data: displayData, + actionDisplayTitle, + displayFields, + actor: { + id: log.actor.id, + type: log.actor.type, + userUuid: log.actor.userUuid, + attendeeId: log.actor.attendeeId, + name: log.actor.name, + createdAt: log.actor.createdAt, + displayName: enrichedActor.displayName, + displayEmail: enrichedActor.displayEmail, + displayAvatar: enrichedActor.displayAvatar, + }, + }; + } + /** + * Builds a "rescheduled from" log entry for bookings created from a reschedule. + * Fetches the RESCHEDULED log from the previous booking and transforms it + * to show "rescheduled from" context for the current booking. + */ + private async buildRescheduledFromLog({ + fromRescheduleUid, + currentBookingUid, + userTimeZone, + }: { + fromRescheduleUid: string; + currentBookingUid: string; + userTimeZone: string; + }): Promise { + const rescheduledLogs = await this.bookingAuditRepository.findRescheduledLogsOfBooking( + fromRescheduleUid + ); + + // Find the specific log that created this booking by matching rescheduledToUid + const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({ + rescheduledLogs, + rescheduledToBookingUid: currentBookingUid, + }); + + if (!rescheduledLog) { + this.log.error(`No rescheduled log found for booking ${fromRescheduleUid} -> ${currentBookingUid}`); + // Instead of crashing, we ignore because it is important to be able to access other logs as well. + return null; + } + + const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone); + const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data); + + // Transform the display data to show "rescheduled from" instead of "rescheduled to" + // by replacing rescheduledToUid with rescheduledFromUid + const transformedData = { + ...enrichedLog.data, + rescheduledFromUid: fromRescheduleUid, + }; + + return { + ...enrichedLog, + // Override bookingUid to associate with the current booking being viewed + bookingUid: currentBookingUid, + data: transformedData, + // Use a different translation key to show "Rescheduled from" instead of "Rescheduled" + actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({ + fromRescheduleUid, + userTimeZone, + parsedData, + }), + }; + } + /** * Check if user has permission to view audit logs for a booking */ @@ -104,19 +215,6 @@ export class BookingAuditViewerService { } } - /** - * 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 */ @@ -176,4 +274,3 @@ export class BookingAuditViewerService { 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 index d9f1b2be5e..8160f3c1a3 100644 --- a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts +++ b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { formatInTimeZone } from "date-fns-tz"; import { prisma } from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -184,10 +185,13 @@ describe("Booking Audit Integration", () => { expect(auditLog.type).toBe("RECORD_CREATED"); // Verify audit data matches (getDisplayJson returns fields only, not versioned wrapper) + // getDisplayJson formats dates using formatDateTimeInTimeZone (yyyy-MM-dd HH:mm:ss format) const displayData = auditLog.data - expect(displayData.startTime).toBe(booking!.startTime.toISOString()); - expect(displayData.endTime).toBe(booking!.endTime.toISOString()); - expect(displayData.status).toBe(booking!.status); + const expectedStartTime = formatInTimeZone(booking!.startTime, "UTC", "yyyy-MM-dd HH:mm:ss"); + const expectedEndTime = formatInTimeZone(booking!.endTime, "UTC", "yyyy-MM-dd HH:mm:ss"); + expect(displayData?.startTime).toBe(expectedStartTime); + expect(displayData?.endTime).toBe(expectedEndTime); + expect(displayData?.status).toBe(booking!.status); }); it("should enrich actor information with user details", async () => { diff --git a/packages/features/booking-audit/lib/types/bookingAuditTask.ts b/packages/features/booking-audit/lib/types/bookingAuditTask.ts index 2e1aca41fe..699a672a7c 100644 --- a/packages/features/booking-audit/lib/types/bookingAuditTask.ts +++ b/packages/features/booking-audit/lib/types/bookingAuditTask.ts @@ -1,29 +1,77 @@ import { z } from "zod"; import { ActorSchema } from "@calcom/features/bookings/lib/types/actor"; -import { - CreatedAuditActionService, -} from "../actions/CreatedAuditActionService"; +import { CreatedAuditActionService } from "../actions/CreatedAuditActionService"; +import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; +import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService"; +import { CancelledAuditActionService } from "../actions/CancelledAuditActionService"; +import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService"; +import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; +import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService"; +import { RejectedAuditActionService } from "../actions/RejectedAuditActionService"; +import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService"; +import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService"; +import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService"; +import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService"; +/** + * Supported booking audit actions + * Used for runtime validation of action field + */ +export const BookingAuditActionSchema = z.enum([ + "CREATED", + "RESCHEDULED", + "ACCEPTED", + "CANCELLED", + "RESCHEDULE_REQUESTED", + "ATTENDEE_ADDED", + "HOST_NO_SHOW_UPDATED", + "REJECTED", + "ATTENDEE_REMOVED", + "REASSIGNMENT", + "LOCATION_CHANGED", + "ATTENDEE_NO_SHOW_UPDATED", +]); -const baseSchema = z.object({ +export type BookingAuditAction = z.infer; + +type AuditActionData = { + action: T["TYPE"]; + data: z.infer; +}; + +/** + * Producer action data type - discriminated union for type-safe queueing + * Used by the legacy queueAudit method for backwards compatibility + */ +export type BookingAuditTaskProducerActionData = + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData + | AuditActionData; + +/** + * Lean base schema for booking audit task payload + * + * Uses `data: z.unknown()` to avoid large discriminated union. + * The consumer parses with this first, then validates `data` + * with the action-specific schema based on the `action` field. + */ +export const BookingAuditTaskBaseSchema = z.object({ bookingUid: z.string(), actor: ActorSchema, organizationId: z.number().nullable(), timestamp: z.number(), + action: BookingAuditActionSchema, + data: z.unknown(), }); -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; +export type BookingAuditTaskBasePayload = z.infer; diff --git a/packages/features/bookings/lib/types/actor.ts b/packages/features/bookings/lib/types/actor.ts index 118cf72115..0469462a62 100644 --- a/packages/features/bookings/lib/types/actor.ts +++ b/packages/features/bookings/lib/types/actor.ts @@ -32,6 +32,7 @@ export const ActorSchema = z.discriminatedUnion("identifiedBy", [ export type Actor = z.infer; type UserActor = z.infer; type GuestActor = z.infer; +type AttendeeActor = z.infer; type ActorById = z.infer; /** @@ -81,5 +82,15 @@ export function makeActorById(id: string): ActorById { }; } +/** + * Creates an Actor representing an Attendee by attendee ID + */ +export function makeAttendeeActor(attendeeId: number): AttendeeActor { + return { + identifiedBy: "attendee", + attendeeId, + }; +} + // 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/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 765940e730..062a42e33f 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -139,8 +139,8 @@ const buildWhereClauseForActiveBookings = ({ }, ...(!includeNoShowInRRCalculation ? { - OR: [{ noShowHost: false }, { noShowHost: null }], - } + OR: [{ noShowHost: false }, { noShowHost: null }], + } : {}), }, { @@ -159,24 +159,24 @@ const buildWhereClauseForActiveBookings = ({ ...(startDate || endDate ? rrTimestampBasis === RRTimestampBasis.CREATED_AT ? { - createdAt: { - ...(startDate ? { gte: startDate } : {}), - ...(endDate ? { lte: endDate } : {}), - }, - } + createdAt: { + ...(startDate ? { gte: startDate } : {}), + ...(endDate ? { lte: endDate } : {}), + }, + } : { - startTime: { - ...(startDate ? { gte: startDate } : {}), - ...(endDate ? { lte: endDate } : {}), - }, - } + startTime: { + ...(startDate ? { gte: startDate } : {}), + ...(endDate ? { lte: endDate } : {}), + }, + } : {}), ...(virtualQueuesData ? { - routedFromRoutingFormReponse: { - chosenRouteId: virtualQueuesData.chosenRouteId, - }, - } + routedFromRoutingFormReponse: { + chosenRouteId: virtualQueuesData.chosenRouteId, + }, + } : {}), }); @@ -325,7 +325,21 @@ const selectStatementToGetBookingForCalEventBuilder = { }; export class BookingRepository { - constructor(private prismaClient: PrismaClient) {} + constructor(private prismaClient: PrismaClient) { } + + /** + * Gets the fromReschedule field for a booking by UID + * Used to identify if this booking was created from a reschedule + * @param bookingUid - The unique identifier of the booking + * @returns The fromReschedule UID or null if not found/not a rescheduled booking + */ + async getFromRescheduleUid(bookingUid: string): Promise { + const booking = await this.prismaClient.booking.findUnique({ + where: { uid: bookingUid }, + select: { fromReschedule: true }, + }); + return booking?.fromReschedule ?? null; + } async getBookingAttendees(bookingId: number) { return await this.prismaClient.attendee.findMany({ @@ -642,20 +656,20 @@ export class BookingRepository { const currentBookingsAllUsersQueryThree = eventTypeId ? this.prismaClient.booking.findMany({ - where: { - startTime: { lte: endDate }, - endTime: { gte: startDate }, - eventType: { - id: eventTypeId, - requiresConfirmation: true, - requiresConfirmationWillBlockSlot: true, - }, - status: { - in: [BookingStatus.PENDING], - }, + where: { + startTime: { lte: endDate }, + endTime: { gte: startDate }, + eventType: { + id: eventTypeId, + requiresConfirmation: true, + requiresConfirmationWillBlockSlot: true, }, - select: bookingsSelect, - }) + status: { + in: [BookingStatus.PENDING], + }, + }, + select: bookingsSelect, + }) : []; const [resultOne, resultTwo, resultThree] = await Promise.all([ diff --git a/packages/features/di/containers/BookingAuditViewerService.container.ts b/packages/features/di/containers/BookingAuditViewerService.container.ts new file mode 100644 index 0000000000..a5dae8c300 --- /dev/null +++ b/packages/features/di/containers/BookingAuditViewerService.container.ts @@ -0,0 +1,18 @@ +import type { BookingAuditViewerService } from "@calcom/features/booking-audit/lib/service/BookingAuditViewerService"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { prismaModule } from "@calcom/features/di/modules/Prisma"; +import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module"; +import { moduleLoader as bookingAuditViewerServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditViewerService.module"; + +import { createContainer } from "../di"; + +const container = createContainer(); +container.load(DI_TOKENS.PRISMA_MODULE, prismaModule); +bookingAuditRepositoryModuleLoader.loadModule(container); +bookingAuditViewerServiceModuleLoader.loadModule(container); + +export function getBookingAuditViewerService() { + return container.get(BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE); +} + diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index d66d2650da..7f44770fc0 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"; +import type { BookingAuditTaskBasePayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask"; export type TaskerTypes = "internal" | "redis"; type TaskPayloads = { sendWebhook: string; @@ -37,7 +37,7 @@ type TaskPayloads = { responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null; routedEventTypeId?: number | null; }; - bookingAudit: BookingAuditTaskConsumerPayload; + bookingAudit: BookingAuditTaskBasePayload; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string, taskId?: string) => Promise;