From f1011ddd0858ae79c4e7fb65f814f39e4c14654c Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 18 Dec 2025 18:16:24 +0530 Subject: [PATCH] chore: Improves the core booking audit architecture by adding new capabilities and simplifying the existing interfaces. (#25872) * feat(booking-audit): extract core audit system changes from PR 25125 This PR extracts core audit infrastructure changes without integration changes: 1. New action services introduced: - SeatBookedAuditActionService - SeatRescheduledAuditActionService 2. Simplification of ActionService interface: - Streamlined IAuditActionService interface - Reduced TypeScript burden with cleaner type definitions 3. ActionSource support: - Added BookingAuditSource enum (API_V1, API_V2, WEBAPP, WEBHOOK, UNKNOWN) - Added source and operationId fields to BookingAudit model 4. New AuditAction types: - SEAT_BOOKED - SEAT_RESCHEDULED - APP actor type 5. New BookingAuditAccessService: - Permission-based access control for audit logs - Added readTeamAuditLogs and readOrgAuditLogs permissions 6. Fixes in the logs viewer flow: - Enhanced BookingAuditViewerService with improved filtering - Local AttendeeRepository for actor enrichment Changes are contained within packages/features/booking-audit with minimal outside changes (permission registry only). Co-Authored-By: hariom@cal.com * refactor(booking-audit): streamline action handling and enhance localization - Replaced action icon retrieval with a mapping object for improved clarity and performance. - Introduced constants for actor role labels to simplify role retrieval. - Added new localization strings for audit log permission errors and organization requirements. - Updated various service and repository interfaces to enhance type safety and clarity. - Removed deprecated architecture documentation and adjusted related imports for consistency. These changes aim to improve code maintainability and user experience in the booking audit system. * fix(booking-audit): enhance actor role localization and operation ID tracking - Updated actor role labels in the booking logs view to use lowercase for consistency. - Improved localization by wrapping actor role display in a translation function. - Added operationId field to audit logs for better correlation of actions across multiple bookings. - Enhanced BookingAuditViewerService to include operationId in enriched audit logs. - Updated integration tests to verify consistent operationId across related audit logs. These changes aim to improve localization accuracy and facilitate better tracking of user actions in the booking audit system. * feat: integrate credential repository and enhance app actor handling - Added CredentialRepository to manage app credentials, including a method to find credentials by ID. - Updated BookingAudit system to support app actors identified by credential ID, improving actor attribution and audit clarity. - Introduced a new utility function to map app slugs to display names, enhancing the user experience in audit logs. - Modified relevant interfaces and types to accommodate the new credential handling and app actor structure. - Enhanced BookingAuditViewerService to display app names based on credentials, ensuring accurate representation in audit logs. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../booking-audit-producer.service.ts | 4 +- .../booking/logs/views/booking-logs-view.tsx | 156 +-- apps/web/public/static/locales/en/common.json | 7 + .../features/booking-audit/ARCHITECTURE.md | 820 ---------------- packages/features/booking-audit/README.md | 239 +++++ .../di/AuditActorRepository.container.ts | 14 + ...ookingAuditTaskerProducerService.module.ts | 2 + .../di/BookingAuditViewerService.module.ts | 6 + .../lib/actions/AcceptedAuditActionService.ts | 14 +- .../AttendeeAddedAuditActionService.ts | 14 +- ...AttendeeNoShowUpdatedAuditActionService.ts | 14 +- .../AttendeeRemovedAuditActionService.ts | 14 +- .../actions/CancelledAuditActionService.ts | 14 +- .../lib/actions/CreatedAuditActionService.ts | 16 +- .../HostNoShowUpdatedAuditActionService.ts | 14 +- .../lib/actions/IAuditActionService.ts | 43 +- .../LocationChangedAuditActionService.ts | 9 +- .../actions/ReassignmentAuditActionService.ts | 19 +- .../lib/actions/RejectedAuditActionService.ts | 14 +- .../RescheduleRequestedAuditActionService.ts | 14 +- .../actions/RescheduledAuditActionService.ts | 41 +- .../actions/SeatBookedAuditActionService.ts | 91 ++ .../SeatRescheduledAuditActionService.ts | 125 +++ .../booking-audit/lib/getAppNameFromSlug.ts | 18 + .../lib/repository/IAuditActorRepository.ts | 12 +- .../lib/repository/IBookingAuditRepository.ts | 10 +- .../repository/PrismaAuditActorRepository.ts | 54 +- .../PrismaBookingAuditRepository.ts | 45 +- .../lib/service/BookingAuditAccessService.ts | 95 ++ .../BookingAuditActionServiceRegistry.ts | 20 +- .../BookingAuditProducerService.interface.ts | 223 +++-- .../lib/service/BookingAuditTaskConsumer.ts | 268 ++++-- .../BookingAuditTaskerProducerService.ts | 201 +++- .../lib/service/BookingAuditViewerService.ts | 215 +++-- .../BookingAuditAccessService.test.ts | 267 ++++++ .../BookingAuditViewerService.test.ts | 892 ++++++++++++++++++ .../service/booking-audit.integration-test.ts | 617 ++++++++---- .../booking-audit/lib/types/actionSource.ts | 5 + .../lib/types/bookingAuditTask.ts | 91 +- .../features/bookings/di/Attendee.module.ts | 15 + packages/features/bookings/di/tokens.ts | 2 + .../BookingEventHandlerService.ts | 297 +++++- .../bookings/lib/onBookingEvents/types.d.ts | 6 +- .../lib/service/RegularBookingService.ts | 102 +- packages/features/bookings/lib/types/actor.ts | 90 +- .../repositories/AttendeeRepository.ts | 24 + .../repositories/IAttendeeRepository.ts | 9 + .../di/CredentialRepository.module.ts | 22 + .../repositories/CredentialRepository.ts | 9 +- .../pbac/domain/types/permission-registry.ts | 18 + packages/features/tasker/tasker.ts | 4 +- .../features/tasker/tasks/bookingAudit.ts | 21 +- packages/platform/libraries/bookings.ts | 1 + .../migration.sql | 30 + .../migration.sql | 14 + packages/prisma/schema.prisma | 61 +- .../viewer/bookings/getAuditLogs.handler.ts | 47 +- 57 files changed, 3808 insertions(+), 1701 deletions(-) delete mode 100644 packages/features/booking-audit/ARCHITECTURE.md create mode 100644 packages/features/booking-audit/README.md create mode 100644 packages/features/booking-audit/di/AuditActorRepository.container.ts create mode 100644 packages/features/booking-audit/lib/actions/SeatBookedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/getAppNameFromSlug.ts create mode 100644 packages/features/booking-audit/lib/service/BookingAuditAccessService.ts create mode 100644 packages/features/booking-audit/lib/service/__tests__/BookingAuditAccessService.test.ts create mode 100644 packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts create mode 100644 packages/features/booking-audit/lib/types/actionSource.ts create mode 100644 packages/features/bookings/di/Attendee.module.ts create mode 100644 packages/features/bookings/repositories/AttendeeRepository.ts create mode 100644 packages/features/bookings/repositories/IAttendeeRepository.ts create mode 100644 packages/features/credentials/di/CredentialRepository.module.ts create mode 100644 packages/prisma/migrations/20251216074521_enhance_booking_audit_schema/migration.sql create mode 100644 packages/prisma/migrations/20251217090304_enhance_audit_schema/migration.sql 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 059d93dd68..a87a9b7d2a 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 @@ -2,7 +2,7 @@ import { TaskerService } from "@/lib/services/tasker.service"; import { Logger } from "@/lib/logger.bridge"; import { Injectable } from "@nestjs/common"; -import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings"; +import { BookingAuditTaskerProducerService, getAuditActorRepository } from "@calcom/platform-libraries/bookings"; @Injectable() export class BookingAuditProducerService extends BookingAuditTaskerProducerService { @@ -10,7 +10,7 @@ export class BookingAuditProducerService extends BookingAuditTaskerProducerServi super({ tasker: taskerService.getTasker(), log: bridgeLogger, + auditActorRepository: getAuditActorRepository(), }); } } - 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 a23b4af071..d47fb528b1 100644 --- a/apps/web/modules/booking/logs/views/booking-logs-view.tsx +++ b/apps/web/modules/booking/logs/views/booking-logs-view.tsx @@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import dayjs from "@calcom/dayjs"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; -import { Icon } from "@calcom/ui/components/icon"; +import { Icon, type IconName } from "@calcom/ui/components/icon"; import { SkeletonText } from "@calcom/ui/components/skeleton"; import { FilterSearchField, Select } from "@calcom/ui/components/form"; import { Avatar } from "@calcom/ui/components/avatar"; @@ -37,9 +37,10 @@ type AuditLog = { action: string; type: string; timestamp: string; - data: Record | null; + source: string; + displayJson?: Record | null; actionDisplayTitle: TranslationWithParams; - displayFields?: Array<{ labelKey: string; valueKey: string }>; + displayFields?: Array<{ labelKey: string; valueKey: string }> | null; actor: { type: AuditActorType; displayName: string | null; @@ -51,55 +52,38 @@ type AuditLog = { interface BookingLogsFiltersProps { searchTerm: string; onSearchChange: (value: string) => void; - actorFilter: AuditActorType | null; - onActorFilterChange: (value: AuditActorType | null) => void; - actorOptions: Array<{ label: string; value: AuditActorType }>; + actorFilter: string | null; + onActorFilterChange: (value: string | null) => void; + actorOptions: Array<{ label: string; value: string }>; } interface BookingLogsTimelineProps { logs: AuditLog[]; } -const getActionIcon = (action: string) => { - switch (action) { - case "CREATED": - return "calendar"; - case "CANCELLED": - case "REJECTED": - return "ban"; - case "ACCEPTED": - return "check"; - case "RESCHEDULED": - case "RESCHEDULE_REQUESTED": - return "pencil"; - case "REASSIGNMENT": - case "ATTENDEE_ADDED": - case "ATTENDEE_REMOVED": - return "user-check"; - case "LOCATION_CHANGED": - return "map-pin"; - case "HOST_NO_SHOW_UPDATED": - case "ATTENDEE_NO_SHOW_UPDATED": - return "ban"; - default: - return "sparkles"; - } -}; +const ACTION_ICON_MAP: Record = { + CREATED: "calendar", + CANCELLED: "ban", + REJECTED: "ban", + ACCEPTED: "check", + RESCHEDULED: "pencil", + RESCHEDULE_REQUESTED: "pencil", + REASSIGNMENT: "user-check", + ATTENDEE_ADDED: "user-check", + ATTENDEE_REMOVED: "user-check", + LOCATION_CHANGED: "map-pin", + HOST_NO_SHOW_UPDATED: "ban", + ATTENDEE_NO_SHOW_UPDATED: "ban", +} as const; -const getActorRoleLabel = (actorType: AuditActorType): string | null => { - switch (actorType) { - case "GUEST": - return "Guest"; - case "ATTENDEE": - return "Attendee"; - case "SYSTEM": - return null; - case "USER": - return null; - default: - return null; - } -}; + +const ACTOR_ROLE_LABEL_MAP: Record = { + GUEST: "guest", + ATTENDEE: "attendee", + SYSTEM: null, + USER: null, + APP: null, +} as const; function BookingLogsFilters({ searchTerm, @@ -127,9 +111,9 @@ function BookingLogsFilters({ value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }} onChange={(option) => { if (!option) return; - onActorFilterChange((option.value as AuditActorType) || null); + onActorFilterChange(option.value || null); }} - options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]} + options={[{ label: t("all"), value: "" }, ...actorOptions]} /> @@ -236,14 +220,13 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { const isLast = index === logs.length - 1; const isExpanded = expandedLogIds.has(log.id); const showJson = showJsonMap[log.id] || false; - const actorRole = getActorRoleLabel(log.actor.type); - + const actorRole = ACTOR_ROLE_LABEL_MAP[log.actor.type] ?? null; return (
- +
{!isLast && ( @@ -268,7 +251,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { )} {log.actor.displayName} - {actorRole && ` (${actorRole})`} + {actorRole && {` (${t(actorRole)})`}} • {dayjs(log.timestamp).fromNow()} @@ -305,6 +288,10 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { {t("actor")} {log.actor.displayName || log.actor.type}
+
+ {t("source")} + {log.source} +
{t("timestamp")} @@ -313,7 +300,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { {dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
- {log.data && Object.keys(log.data).length > 0 && ( + {log.displayJson && Object.keys(log.displayJson).length > 0 && (
- {showJson && } + {showJson && }
)} @@ -342,15 +329,55 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { ); } +function useBookingLogsFilters( + auditLogs: AuditLog[], + searchTerm: string, + actorFilter: string | null +) { + const filteredLogs = auditLogs.filter((log) => { + const matchesSearch = + !searchTerm || + log.action.toLowerCase().includes(searchTerm.toLowerCase()) || + log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesActor = !actorFilter || log.actor.displayName === actorFilter; + + return matchesSearch && matchesActor; + }); + + const uniqueActorNames = Array.from( + new Set( + auditLogs + .map((log) => log.actor.displayName) + .filter((name): name is string => Boolean(name)) + ) + ); + + const actorOptions = uniqueActorNames.map((actorName) => ({ + label: actorName, + value: actorName, + })); + + return { filteredLogs, actorOptions }; +} + export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(""); - const [actorFilter, setActorFilter] = useState(null); + const [actorFilter, setActorFilter] = useState(null); const { t } = useLocale(); const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({ bookingUid, }); + const auditLogs = data?.auditLogs || []; + + const { filteredLogs, actorOptions } = useBookingLogsFilters( + auditLogs, + searchTerm, + actorFilter + ); + if (error) { return (
@@ -376,27 +403,6 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) { ); } - const auditLogs = data?.auditLogs || []; - - // Apply filters - const filteredLogs = auditLogs.filter((log) => { - const matchesSearch = - !searchTerm || - log.action.toLowerCase().includes(searchTerm.toLowerCase()) || - log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesActor = !actorFilter || log.actor.type === actorFilter; - - return matchesSearch && matchesActor; - }); - - const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))) as AuditActorType[]; - - const actorOptions = uniqueActorTypes.map((actorType) => ({ - label: actorType, - value: actorType, - })); - return (
(auditActorRepositoryModule.token); +} diff --git a/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts b/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts index 5f607aff3c..9466a6d8b8 100644 --- a/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts +++ b/packages/features/booking-audit/di/BookingAuditTaskerProducerService.module.ts @@ -2,6 +2,7 @@ import { BookingAuditTaskerProducerService } from "@calcom/features/booking-audi import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; import { moduleLoader as taskerModuleLoader } from "@calcom/features/di/shared/services/tasker.service"; import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service"; +import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module"; import { createModule, bindModuleToClassOnToken } from "../../di/di"; @@ -17,6 +18,7 @@ const loadModule = bindModuleToClassOnToken({ depsMap: { tasker: taskerModuleLoader, log: loggerModuleLoader, + auditActorRepository: auditActorRepositoryModuleLoader, }, }); diff --git a/packages/features/booking-audit/di/BookingAuditViewerService.module.ts b/packages/features/booking-audit/di/BookingAuditViewerService.module.ts index a78ddf55ef..629297b08a 100644 --- a/packages/features/booking-audit/di/BookingAuditViewerService.module.ts +++ b/packages/features/booking-audit/di/BookingAuditViewerService.module.ts @@ -3,7 +3,10 @@ import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/token import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module"; import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User"; import { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking"; +import { moduleLoader as attendeeRepositoryModuleLoader } from "@calcom/features/bookings/di/Attendee.module"; +import { moduleLoader as membershipRepositoryModuleLoader } from "@calcom/features/users/di/MembershipRepository.module"; import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service"; +import { moduleLoader as credentialRepositoryModuleLoader } from "@calcom/features/credentials/di/CredentialRepository.module"; import { createModule, bindModuleToClassOnToken } from "../../di/di"; @@ -22,7 +25,10 @@ const loadModule = bindModuleToClassOnToken({ bookingAuditRepository: bookingAuditRepositoryModuleLoader, userRepository: userRepositoryModuleLoader, bookingRepository: bookingRepositoryModuleLoader, + membershipRepository: membershipRepositoryModuleLoader, + attendeeRepository: attendeeRepositoryModuleLoader, log: loggerModuleLoader, + credentialRepository: credentialRepositoryModuleLoader, }, }); diff --git a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts index 14622da9d5..f939272547 100644 --- a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { BookingStatusChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Accepted Audit Action Service @@ -15,8 +15,7 @@ const fieldsSchemaV1 = z.object({ status: BookingStatusChangeSchema, }); -export class AcceptedAuditActionService - implements IAuditActionService { +export class AcceptedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "ACCEPTED" as const; private static dataSchemaV1 = z.object({ @@ -60,17 +59,14 @@ export class AcceptedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.accepted" }; } getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): AcceptedAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): AcceptedAuditDisplayData { + const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); return { previousStatus: fields.status.old ?? null, newStatus: fields.status.new ?? null, diff --git a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts index 9c2d9abfd1..7df6c3cebc 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { StringArrayChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Attendee Added Audit Action Service @@ -14,8 +14,7 @@ const fieldsSchemaV1 = z.object({ attendees: StringArrayChangeSchema, }); -export class AttendeeAddedAuditActionService - implements IAuditActionService { +export class AttendeeAddedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "ATTENDEE_ADDED" as const; private static dataSchemaV1 = z.object({ @@ -59,17 +58,14 @@ export class AttendeeAddedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.attendee_added" }; } getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): AttendeeAddedAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): AttendeeAddedAuditDisplayData { + const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); const previousAttendeesSet = new Set(fields.attendees.old ?? []); const addedAttendees = fields.attendees.new.filter( (email) => !previousAttendeesSet.has(email) diff --git a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts index 6de87ec2f4..5128fca841 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { BooleanChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Attendee No-Show Updated Audit Action Service @@ -14,8 +14,7 @@ const fieldsSchemaV1 = z.object({ noShowAttendee: BooleanChangeSchema, }); -export class AttendeeNoShowUpdatedAuditActionService - implements IAuditActionService { +export class AttendeeNoShowUpdatedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const; private static dataSchemaV1 = z.object({ @@ -59,17 +58,14 @@ export class AttendeeNoShowUpdatedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.attendee_no_show_updated" }; } getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): AttendeeNoShowUpdatedAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): AttendeeNoShowUpdatedAuditDisplayData { + const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); return { noShowAttendee: fields.noShowAttendee.new, previousNoShowAttendee: fields.noShowAttendee.old ?? null, diff --git a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts index 8fc6c63374..86062de959 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { StringArrayChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Attendee Removed Audit Action Service @@ -14,8 +14,7 @@ const fieldsSchemaV1 = z.object({ attendees: StringArrayChangeSchema, }); -export class AttendeeRemovedAuditActionService - implements IAuditActionService { +export class AttendeeRemovedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "ATTENDEE_REMOVED" as const; private static dataSchemaV1 = z.object({ @@ -59,17 +58,14 @@ export class AttendeeRemovedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.attendee_removed" }; } getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): AttendeeRemovedAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): AttendeeRemovedAuditDisplayData { + const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); const remainingAttendeesSet = new Set(fields.attendees.new ?? []); const removedAttendees = (fields.attendees.old ?? []).filter( (email) => !remainingAttendeesSet.has(email) diff --git a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts index 4b2a8b1e1f..81607f2d85 100644 --- a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { StringChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Cancelled Audit Action Service @@ -16,8 +16,7 @@ const fieldsSchemaV1 = z.object({ status: StringChangeSchema, }); -export class CancelledAuditActionService - implements IAuditActionService { +export class CancelledAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "CANCELLED" as const; private static dataSchemaV1 = z.object({ @@ -61,17 +60,14 @@ export class CancelledAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.cancelled" }; } getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): CancelledAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): CancelledAuditDisplayData { + const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); return { cancellationReason: fields.cancellationReason.new ?? null, previousReason: fields.cancellationReason.old ?? null, diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index b4f59469b9..ad616eba04 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { BookingStatus } from "@calcom/prisma/enums"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Created Audit Action Service @@ -17,10 +17,7 @@ const fieldsSchemaV1 = z.object({ status: z.nativeEnum(BookingStatus), }); -export class CreatedAuditActionService implements IAuditActionService< - typeof fieldsSchemaV1, - typeof fieldsSchemaV1 -> { +export class CreatedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "CREATED" as const; private static dataSchemaV1 = z.object({ @@ -61,18 +58,15 @@ export class CreatedAuditActionService implements IAuditActionService< return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.created" }; } getDisplayJson({ storedData, userTimeZone, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): CreatedAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): CreatedAuditDisplayData { + const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); const timeZone = userTimeZone; return { diff --git a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts index 6901435217..8a3877afd8 100644 --- a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { BooleanChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Host No-Show Updated Audit Action Service @@ -14,8 +14,7 @@ const fieldsSchemaV1 = z.object({ noShowHost: BooleanChangeSchema, }); -export class HostNoShowUpdatedAuditActionService - implements IAuditActionService { +export class HostNoShowUpdatedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const; private static dataSchemaV1 = z.object({ @@ -59,17 +58,14 @@ export class HostNoShowUpdatedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.host_no_show_updated" }; } getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): HostNoShowUpdatedAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): HostNoShowUpdatedAuditDisplayData { + const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); return { noShowHost: fields.noShowHost.new, previousNoShowHost: fields.noShowHost.old ?? null, diff --git a/packages/features/booking-audit/lib/actions/IAuditActionService.ts b/packages/features/booking-audit/lib/actions/IAuditActionService.ts index f86133b9d8..ae7f7a7435 100644 --- a/packages/features/booking-audit/lib/actions/IAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/IAuditActionService.ts @@ -1,5 +1,3 @@ -import { z } from "zod"; - /** * Represents a component that can be interpolated into translations * Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.) @@ -20,20 +18,35 @@ export type TranslationWithParams = { components?: TranslationComponent[]; }; +/** + * This is agnostic of the action and is common for all actions + */ +export type BaseStoredAuditData = { + version: number; + fields: Record; +}; + +export type GetDisplayJsonParams = { + storedData: BaseStoredAuditData; + userTimeZone: string; +}; + +export type GetDisplayTitleParams = { + storedData: BaseStoredAuditData; + userTimeZone: string; +}; + /** * Interface for Audit Action Services * * Defines the contract that all audit action services must implement. * Uses composition with AuditActionServiceHelper to provide common functionality - * while maintaining type safety and flexibility for versioned schemas. + * while maintaining runtime type safety through Zod schema validation. * - * @template TLatestFieldsSchema - The Zod schema type for the latest version's audit fields (write operations) - * @template TStoredFieldsSchema - The Zod schema type for all supported versions' audit fields (read operations, union type) + * All methods use common base types at the interface level, while implementations + * validate to their specific schemas internally. */ -export interface IAuditActionService< - TLatestFieldsSchema extends z.ZodTypeAny, - TStoredFieldsSchema extends z.ZodTypeAny -> { +export interface IAuditActionService { /** * Current version number for this action type */ @@ -44,7 +57,7 @@ export interface IAuditActionService< * @param fields - Raw input fields (just the audit fields) * @returns Parsed data with version wrapper { version, fields } */ - getVersionedData(fields: unknown): { version: number; fields: z.infer }; + getVersionedData(fields: unknown): BaseStoredAuditData; /** * Parse stored audit record (includes version wrapper) @@ -52,7 +65,7 @@ export interface IAuditActionService< * @param data - Stored data from database (can be any version) * @returns Parsed stored data { version, fields } - version may differ from current VERSION */ - parseStored(data: unknown): { version: number; fields: z.infer }; + parseStored(data: unknown): BaseStoredAuditData; /** * Extract version number from stored data @@ -69,7 +82,7 @@ export interface IAuditActionService< * @param params.userTimeZone - User's timezone for datetime formatting (required) * @returns The fields object without version wrapper and we decide what fields to show to the client */ - getDisplayJson?(params: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): unknown; + getDisplayJson?(params: GetDisplayJsonParams): Record; /** * Get the display title for the audit action @@ -80,7 +93,7 @@ export interface IAuditActionService< * @param params.userTimeZone - User's timezone for date formatting (required) * @returns Translation key with optional interpolation params */ - getDisplayTitle(params: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise; + getDisplayTitle(params: GetDisplayTitleParams): Promise; /** * Returns additional display fields with translation keys for frontend rendering @@ -88,7 +101,7 @@ export interface IAuditActionService< * @param storedData - Parsed stored data { version, fields } * @returns Array of field objects with label and value translation keys */ - getDisplayFields?(storedData: { version: number; fields: z.infer }): Array<{ + getDisplayFields?(storedData: BaseStoredAuditData): Array<{ labelKey: string; // Translation key for field label valueKey: string; // Translation key for field value }>; @@ -111,6 +124,6 @@ export interface IAuditActionService< /** * Always set, either migrated or original */ - latestData: z.infer; + latestData: Record; }; } \ No newline at end of file diff --git a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts index c939e27100..4e218d8d3d 100644 --- a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { getHumanReadableLocationValue } from "@calcom/app-store/locations"; import { StringChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams } from "./IAuditActionService"; import { getTranslation } from "@calcom/lib/server/i18n"; /** * Location Changed Audit Action Service @@ -15,8 +15,7 @@ const fieldsSchemaV1 = z.object({ location: StringChangeSchema, }); -export class LocationChangedAuditActionService - implements IAuditActionService { +export class LocationChangedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "LOCATION_CHANGED" as const; private static dataSchemaV1 = z.object({ @@ -60,8 +59,8 @@ export class LocationChangedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle({ storedData }: { storedData: { version: number; fields: z.infer }; userTimeZone?: string }): Promise { - const { fields } = storedData; + async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise { + const { fields } = this.parseStored(storedData); // TODO: Ideally we want to translate the location label to the user's locale // We currently don't accept requesting user's translate fn here, fix it later. const t = await getTranslation("en", "common"); diff --git a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts index 70ecacabd3..c1027655bf 100644 --- a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService"; /** * Reassignment Audit Action Service @@ -20,8 +20,7 @@ const fieldsSchemaV1 = z.object({ title: StringChangeSchema.optional(), }); -export class ReassignmentAuditActionService - implements IAuditActionService { +export class ReassignmentAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "REASSIGNMENT" as const; private static dataSchemaV1 = z.object({ @@ -65,8 +64,8 @@ export class ReassignmentAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle({ storedData }: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { - const { fields } = storedData; + async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise { + const { fields } = this.parseStored(storedData); const user = await this.userRepository.findById({ id: fields.assignedToId.new }); const reassignedToName = user?.name || "Unknown"; return { @@ -77,23 +76,19 @@ export class ReassignmentAuditActionService getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): ReassignmentAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): ReassignmentAuditDisplayData { + const { fields } = this.parseStored(storedData); return { newAssignedToId: fields.assignedToId.new, reassignmentReason: fields.reassignmentReason.new ?? null, }; } - getDisplayFields(storedData: { version: number; fields: z.infer }): Array<{ + getDisplayFields(storedData: BaseStoredAuditData): Array<{ labelKey: string; valueKey: string; }> { const { fields } = storedData; - const typeTranslationKey = `booking_audit_action.assignmentType_${fields.reassignmentType}`; return [ diff --git a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts index 3d99c5eb75..6017d2ecec 100644 --- a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { StringChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Rejected Audit Action Service @@ -15,8 +15,7 @@ const fieldsSchemaV1 = z.object({ status: StringChangeSchema, }); -export class RejectedAuditActionService - implements IAuditActionService { +export class RejectedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "REJECTED" as const; private static dataSchemaV1 = z.object({ @@ -60,17 +59,14 @@ export class RejectedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.rejected" }; } getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): RejectedAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): RejectedAuditDisplayData { + const { fields } = this.parseStored(storedData); return { rejectionReason: fields.rejectionReason.new ?? null, previousReason: fields.rejectionReason.old ?? null, diff --git a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts index f1f4577604..a98b55e636 100644 --- a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; /** * Reschedule Requested Audit Action Service @@ -16,8 +16,7 @@ const fieldsSchemaV1 = z.object({ rescheduled: BooleanChangeSchema.optional(), }); -export class RescheduleRequestedAuditActionService - implements IAuditActionService { +export class RescheduleRequestedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "RESCHEDULE_REQUESTED" as const; private static dataSchemaV1 = z.object({ @@ -61,17 +60,14 @@ export class RescheduleRequestedAuditActionService return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: { storedData: { version: number; fields: z.infer }; userTimeZone: string }): Promise { + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.reschedule_requested" }; } getDisplayJson({ storedData, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): RescheduleRequestedAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): RescheduleRequestedAuditDisplayData { + const { fields } = this.parseStored(storedData); return { reason: fields.cancellationReason.new ?? null, requestedBy: fields.cancelledBy.new ?? null, diff --git a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts index a4e89e9aad..fb28fc86be 100644 --- a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { StringChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService"; /** * Rescheduled Audit Action Service @@ -16,8 +16,7 @@ const fieldsSchemaV1 = z.object({ rescheduledToUid: StringChangeSchema, }); -export class RescheduledAuditActionService - implements IAuditActionService { +export class RescheduledAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "RESCHEDULED" as const; private static dataSchemaV1 = z.object({ @@ -64,19 +63,17 @@ export class RescheduledAuditActionService async getDisplayTitle({ storedData, userTimeZone, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): Promise { - const rescheduledToUid = storedData.fields.rescheduledToUid.new; + }: GetDisplayTitleParams): Promise { + const { fields } = this.parseStored(storedData); + const rescheduledToUid = fields.rescheduledToUid.new; const timeZone = userTimeZone; // Format dates in user timezone - const oldDate = storedData.fields.startTime.old - ? AuditActionServiceHelper.formatDateInTimeZone(storedData.fields.startTime.old, timeZone) + const oldDate = fields.startTime.old + ? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, timeZone) : ""; - const newDate = storedData.fields.startTime.new - ? AuditActionServiceHelper.formatDateInTimeZone(storedData.fields.startTime.new, timeZone) + const newDate = fields.startTime.new + ? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, timeZone) : ""; return { @@ -92,20 +89,21 @@ export class RescheduledAuditActionService getDisplayTitleForRescheduledFromLog({ fromRescheduleUid, userTimeZone, - parsedData, + storedData, }: { fromRescheduleUid: string; userTimeZone: string; - parsedData: { version: number; fields: z.infer }; + storedData: BaseStoredAuditData; }): TranslationWithParams { const timeZone = userTimeZone; + const { fields } = this.parseStored(storedData); // Format dates in user timezone - const oldDate = parsedData.fields.startTime.old - ? AuditActionServiceHelper.formatDateInTimeZone(parsedData.fields.startTime.old, timeZone) + const oldDate = fields.startTime.old + ? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, timeZone) : ""; - const newDate = parsedData.fields.startTime.new - ? AuditActionServiceHelper.formatDateInTimeZone(parsedData.fields.startTime.new, timeZone) + const newDate = fields.startTime.new + ? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, timeZone) : ""; return { @@ -121,11 +119,8 @@ export class RescheduledAuditActionService getDisplayJson({ storedData, userTimeZone, - }: { - storedData: { version: number; fields: z.infer }; - userTimeZone: string; - }): RescheduledAuditDisplayData { - const { fields } = storedData; + }: GetDisplayJsonParams): RescheduledAuditDisplayData { + const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); const timeZone = userTimeZone; return { diff --git a/packages/features/booking-audit/lib/actions/SeatBookedAuditActionService.ts b/packages/features/booking-audit/lib/actions/SeatBookedAuditActionService.ts new file mode 100644 index 0000000000..0bc942c9df --- /dev/null +++ b/packages/features/booking-audit/lib/actions/SeatBookedAuditActionService.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; + +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { GetDisplayTitleParams, GetDisplayJsonParams, IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Seat Booked Audit Action Service + * Handles SEAT_BOOKED action with per-action versioning + * + * Note: SEAT_BOOKED action captures initial state, so it doesn't use { old, new } pattern + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + seatReferenceUid: z.string(), + attendeeEmail: z.string(), + attendeeName: z.string(), + startTime: z.number(), + endTime: z.number(), +}); + +export class SeatBookedAuditActionService implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "SEAT_BOOKED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = SeatBookedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = SeatBookedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: SeatBookedAuditActionService.latestFieldsSchema, + storedDataSchema: SeatBookedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(_: GetDisplayTitleParams): Promise { + return { key: "booking_audit_action.seat_booked" }; + } + + getDisplayJson({ + storedData, + userTimeZone, + }: GetDisplayJsonParams): SeatBookedAuditDisplayData { + const { fields } = this.parseStored(storedData); + + return { + seatReferenceUid: fields.seatReferenceUid, + attendeeEmail: fields.attendeeEmail, + attendeeName: fields.attendeeName, + startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, userTimeZone), + endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, userTimeZone), + }; + } +} + +export type SeatBookedAuditData = z.infer; + +export type SeatBookedAuditDisplayData = { + seatReferenceUid: string; + attendeeEmail: string; + attendeeName: string; + startTime: string; + endTime: string; +}; diff --git a/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts new file mode 100644 index 0000000000..553d752536 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams, TranslationWithParams } from "./IAuditActionService"; +/** + * Seat Rescheduled Audit Action Service + * Handles SEAT_RESCHEDULED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + seatReferenceUid: z.string(), + attendeeEmail: z.string(), + startTime: StringChangeSchema, + endTime: StringChangeSchema, + rescheduledToBookingUid: StringChangeSchema, +}); + +export class SeatRescheduledAuditActionService implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "SEAT_RESCHEDULED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = SeatRescheduledAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = SeatRescheduledAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof SeatRescheduledAuditActionService.latestFieldsSchema, + typeof SeatRescheduledAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: SeatRescheduledAuditActionService.latestFieldsSchema, + storedDataSchema: SeatRescheduledAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle({ + storedData, + userTimeZone, + }: GetDisplayTitleParams): Promise { + const { fields } = this.parseStored(storedData); + const rescheduledToBookingUid = fields.rescheduledToBookingUid.new; + + // Format dates in user timezone + const oldDate = fields.startTime.old + ? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, userTimeZone) + : ""; + const newDate = fields.startTime.new + ? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, userTimeZone) + : ""; + + return { + key: "booking_audit_action.seat_rescheduled", + params: { + oldDate, + newDate, + }, + components: rescheduledToBookingUid ? [{ type: "link", href: `/booking/${rescheduledToBookingUid}/logs` }] : undefined, + }; + } + + getDisplayJson({ + storedData, + userTimeZone, + }: GetDisplayJsonParams): SeatRescheduledAuditDisplayData { + const { fields } = this.parseStored(storedData); + + return { + seatReferenceUid: fields.seatReferenceUid, + attendeeEmail: fields.attendeeEmail, + previousStartTime: fields.startTime.old + ? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.old, userTimeZone) + : null, + newStartTime: fields.startTime.new + ? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.new, userTimeZone) + : null, + previousEndTime: fields.endTime.old + ? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.old, userTimeZone) + : null, + newEndTime: fields.endTime.new + ? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.new, userTimeZone) + : null, + rescheduledToBookingUid: fields.rescheduledToBookingUid.new ?? null, + }; + } +} + +export type SeatRescheduledAuditData = z.infer; + +export type SeatRescheduledAuditDisplayData = { + seatReferenceUid: string; + attendeeEmail: string; + previousStartTime: string | null; + newStartTime: string | null; + previousEndTime: string | null; + newEndTime: string | null; + rescheduledToBookingUid: string | null; +}; diff --git a/packages/features/booking-audit/lib/getAppNameFromSlug.ts b/packages/features/booking-audit/lib/getAppNameFromSlug.ts new file mode 100644 index 0000000000..d12efc9a61 --- /dev/null +++ b/packages/features/booking-audit/lib/getAppNameFromSlug.ts @@ -0,0 +1,18 @@ +/** + * Maps app slugs to display names for payment apps + * Falls back to slug itself if not in map + */ +const APP_SLUG_TO_NAME: Record = { + stripe: "Stripe", + paypal: "Paypal", + alby: "Alby", + hitpay: "HitPay", + btcpayserver: "BTCPayServer", +}; + +export function getAppNameFromSlug({ appSlug }: { appSlug: string | null }): string { + if (!appSlug) { + return "Unknown App"; + } + return APP_SLUG_TO_NAME[appSlug] ?? appSlug; +} diff --git a/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts b/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts index 6ff4eb4ec0..76f40b0b2c 100644 --- a/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts +++ b/packages/features/booking-audit/lib/repository/IAuditActorRepository.ts @@ -1,10 +1,11 @@ -export type AuditActorType = "USER" | "GUEST" | "ATTENDEE" | "SYSTEM"; +export type AuditActorType = "USER" | "GUEST" | "ATTENDEE" | "SYSTEM" | "APP"; type AuditActor = { id: string; type: AuditActorType; userUuid: string | null; attendeeId: number | null; + credentialId: number | null; email: string | null; phone: string | null; name: string | null; @@ -12,9 +13,12 @@ type AuditActor = { } export interface IAuditActorRepository { findByUserUuid(userUuid: string): Promise; - findSystemActorOrThrow(): Promise; createIfNotExistsUserActor(params: { userUuid: string }): Promise; - createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null): Promise; - findByAttendeeId(attendeeId: number): Promise; + createIfNotExistsAttendeeActor(params: { attendeeId: number }): Promise; + createIfNotExistsGuestActor(params: { email: string | null; name: string | null; phone: string | null }): Promise; + createIfNotExistsAppActor(params: + | { credentialId: number } + | { email: string; name: string } + ): Promise; } diff --git a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts index 199f903a8e..8bd67a3024 100644 --- a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "@calcom/types/Json"; import type { AuditActorType } from "./IAuditActorRepository"; +import type { ActionSource } from "../types/actionSource"; export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED" @@ -10,7 +11,7 @@ export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DEL * They are reserved in the enum for potential future use but should not appear in audit logs. * Use the CREATED action to capture initial booking status instead. */ -export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" +export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED" export type BookingAuditCreateInput = { bookingUid: string; actorId: string; @@ -18,6 +19,8 @@ export type BookingAuditCreateInput = { data: JsonValue; type: BookingAuditType; timestamp: Date; + source: ActionSource; + operationId: string; } type BookingAudit = { @@ -30,6 +33,8 @@ type BookingAudit = { createdAt: Date; updatedAt: Date; data: JsonValue; + source: ActionSource; + operationId: string; } export type BookingAuditWithActor = BookingAudit & { @@ -38,6 +43,7 @@ export type BookingAuditWithActor = BookingAudit & { type: AuditActorType; userUuid: string | null; attendeeId: number | null; + credentialId: number | null; name: string | null; createdAt: Date; }; @@ -49,6 +55,8 @@ export interface IBookingAuditRepository { */ create(bookingAudit: BookingAuditCreateInput): Promise; + createMany(bookingAudits: BookingAuditCreateInput[]): Promise<{ count: number }>; + /** * Retrieves all audit logs for a specific booking * @param bookingUid - The unique identifier of the booking diff --git a/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts b/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts index a1a067c00b..f3b7fd3062 100644 --- a/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts +++ b/packages/features/booking-audit/lib/repository/PrismaAuditActorRepository.ts @@ -1,8 +1,6 @@ import type { PrismaClient } from "@calcom/prisma/client"; import type { IAuditActorRepository } from "./IAuditActorRepository"; -const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; - type Dependencies = { prismaClient: PrismaClient; } @@ -14,18 +12,6 @@ export class PrismaAuditActorRepository implements IAuditActorRepository { }); } - async findSystemActorOrThrow() { - const actor = await this.deps.prismaClient.auditActor.findUnique({ - where: { id: SYSTEM_ACTOR_ID }, - }); - - if (!actor) { - throw new Error("System actor not found"); - } - - return actor; - } - async createIfNotExistsUserActor(params: { userUuid: string }) { return this.deps.prismaClient.auditActor.upsert({ where: { userUuid: params.userUuid }, @@ -37,7 +23,8 @@ export class PrismaAuditActorRepository implements IAuditActorRepository { }); } - async createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null) { + async createIfNotExistsGuestActor(params: { email: string | null; name: string | null; phone: string | null }) { + const { email, name, phone } = params; const normalizedEmail = email && email.trim() !== "" ? email : null; const normalizedName = name && name.trim() !== "" ? name : null; const normalizedPhone = phone && phone.trim() !== "" ? phone : null; @@ -101,9 +88,40 @@ export class PrismaAuditActorRepository implements IAuditActorRepository { }); } - async findByAttendeeId(attendeeId: number) { - return this.deps.prismaClient.auditActor.findUnique({ - where: { attendeeId }, + async createIfNotExistsAttendeeActor(params: { attendeeId: number }) { + return this.deps.prismaClient.auditActor.upsert({ + where: { attendeeId: params.attendeeId }, + create: { + type: "ATTENDEE", + attendeeId: params.attendeeId, + }, + update: {}, + }); + } + + async createIfNotExistsAppActor(params: + | { credentialId: number } + | { email: string; name: string } + ) { + if ('credentialId' in params) { + return this.deps.prismaClient.auditActor.upsert({ + where: { credentialId: params.credentialId }, + create: { + type: "APP", + credentialId: params.credentialId, + }, + update: {}, + }); + } + + return this.deps.prismaClient.auditActor.upsert({ + where: { email: params.email }, + create: { + type: "APP", + email: params.email, + name: params.name, + }, + update: {}, }); } diff --git a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts index 61de783d3c..fac6111eb0 100644 --- a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts @@ -15,10 +15,25 @@ const safeActorSelect = { type: true, userUuid: true, attendeeId: true, + credentialId: true, name: true, createdAt: true, } as const; +const safeBookingAuditSelect = { + id: true, + bookingUid: true, + actorId: true, + action: true, + type: true, + timestamp: true, + source: true, + operationId: true, + data: true, + createdAt: true, + updatedAt: true, +} as const; + export class PrismaBookingAuditRepository implements IBookingAuditRepository { constructor(private readonly deps: Dependencies) { } @@ -30,17 +45,36 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository { action: bookingAudit.action, type: bookingAudit.type, timestamp: bookingAudit.timestamp, + source: bookingAudit.source, + operationId: bookingAudit.operationId, data: bookingAudit.data === null ? undefined : bookingAudit.data, }, }); } + async createMany(bookingAudits: BookingAuditCreateInput[]) { + const result = await this.deps.prismaClient.bookingAudit.createMany({ + data: bookingAudits.map((bookingAudit) => ({ + bookingUid: bookingAudit.bookingUid, + actorId: bookingAudit.actorId, + action: bookingAudit.action, + type: bookingAudit.type, + timestamp: bookingAudit.timestamp, + source: bookingAudit.source, + operationId: bookingAudit.operationId, + data: bookingAudit.data === null ? undefined : bookingAudit.data, + })), + }); + return { count: result.count }; + } + async findAllForBooking(bookingUid: string): Promise { return this.deps.prismaClient.bookingAudit.findMany({ where: { bookingUid, }, - include: { + select: { + ...safeBookingAuditSelect, actor: { select: safeActorSelect, }, @@ -57,14 +91,13 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository { bookingUid, action: "RESCHEDULED", }, - include: { + select: { + ...safeBookingAuditSelect, actor: { - select: safeActorSelect, + select: safeActorSelect }, }, - orderBy: { - timestamp: "desc", - }, + orderBy: { timestamp: "desc" }, }); } } diff --git a/packages/features/booking-audit/lib/service/BookingAuditAccessService.ts b/packages/features/booking-audit/lib/service/BookingAuditAccessService.ts new file mode 100644 index 0000000000..c5c05af498 --- /dev/null +++ b/packages/features/booking-audit/lib/service/BookingAuditAccessService.ts @@ -0,0 +1,95 @@ +import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { MembershipRole } from "@calcom/prisma/enums"; + +export enum BookingAuditErrorCode { + ORGANIZATION_ID_REQUIRED = "ORGANIZATION_ID_REQUIRED", + BOOKING_NOT_FOUND_OR_PERMISSION_DENIED = "BOOKING_NOT_FOUND_OR_PERMISSION_DENIED", + BOOKING_HAS_NO_OWNER = "BOOKING_HAS_NO_OWNER", + OWNER_NOT_IN_ORGANIZATION = "OWNER_NOT_IN_ORGANIZATION", + PERMISSION_DENIED = "PERMISSION_DENIED", +} + +export class BookingAuditPermissionError extends Error { + constructor(public readonly code: BookingAuditErrorCode) { + super(code); + this.name = "BookingAuditPermissionError"; + } +} + +interface BookingAuditAccessServiceDeps { + bookingRepository: BookingRepository; + membershipRepository: MembershipRepository; +} + +/** + * BookingAuditAccessService - Service for checking access permissions to booking audit logs + * Audit logs are admin-only for compliance and security purposes. + * Regular users (including booking organizers and hosts) cannot view audit logs. + */ +export class BookingAuditAccessService { + private readonly bookingRepository: BookingRepository; + private readonly membershipRepository: MembershipRepository; + private readonly permissionCheckService: PermissionCheckService; + + constructor(deps: BookingAuditAccessServiceDeps) { + this.bookingRepository = deps.bookingRepository; + this.membershipRepository = deps.membershipRepository; + this.permissionCheckService = new PermissionCheckService(); + } + + /** + * Check if user has permission to view audit logs for a booking + * Throws BookingAuditPermissionError if access is denied + */ + async assertPermissions({ bookingUid, userId, organizationId }: { bookingUid: string, userId: number, organizationId: number | null }): Promise { + if (!organizationId) { + throw new BookingAuditPermissionError(BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED); + } + + const booking = await this.bookingRepository.findByUidIncludeEventType({ bookingUid }); + if (!booking) { + throw new BookingAuditPermissionError(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED); + } + + const bookingEventType = booking.eventType; + const bookingEventTypeTeamId = bookingEventType?.teamId ?? bookingEventType?.parent?.teamId; + + if (bookingEventTypeTeamId) { + const hasAccess = await this.permissionCheckService.checkPermission({ + userId, + teamId: bookingEventTypeTeamId, + permission: "booking.readTeamAuditLogs", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + if (hasAccess) { + return; + } + } + + const bookingOwnerId = booking.userId; + + if (!bookingOwnerId) { + throw new BookingAuditPermissionError(BookingAuditErrorCode.BOOKING_HAS_NO_OWNER); + } + + const isBookingOwnerMemberOfOrganization = await this.membershipRepository.hasMembership({ userId: bookingOwnerId, teamId: organizationId }); + + if (!isBookingOwnerMemberOfOrganization) { + throw new BookingAuditPermissionError(BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION); + } + + const hasAccess = await this.permissionCheckService.checkPermission({ + userId, + teamId: organizationId, + permission: "booking.readOrgAuditLogs", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + if (hasAccess) { + return; + } + throw new BookingAuditPermissionError(BookingAuditErrorCode.PERMISSION_DENIED); + } +} diff --git a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts index 23ce4913d4..d5c311f21e 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts @@ -15,6 +15,8 @@ import { AttendeeRemovedAuditActionService, type AttendeeRemovedAuditData } from import { ReassignmentAuditActionService, type ReassignmentAuditData } from "../actions/ReassignmentAuditActionService"; import { LocationChangedAuditActionService, type LocationChangedAuditData } from "../actions/LocationChangedAuditActionService"; import { AttendeeNoShowUpdatedAuditActionService, type AttendeeNoShowUpdatedAuditData } from "../actions/AttendeeNoShowUpdatedAuditActionService"; +import { SeatBookedAuditActionService, type SeatBookedAuditData } from "../actions/SeatBookedAuditActionService"; +import { SeatRescheduledAuditActionService, type SeatRescheduledAuditData } from "../actions/SeatRescheduledAuditActionService"; /** * Union type for all audit action data types @@ -32,8 +34,9 @@ export type AuditActionData = | AttendeeRemovedAuditData | ReassignmentAuditData | LocationChangedAuditData - | AttendeeNoShowUpdatedAuditData; - + | AttendeeNoShowUpdatedAuditData + | SeatBookedAuditData + | SeatRescheduledAuditData; /** * BookingAuditActionServiceRegistry @@ -47,12 +50,10 @@ interface BookingAuditActionServiceRegistryDeps { } export class BookingAuditActionServiceRegistry { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly actionServices: Map>; + private readonly actionServices: Map; constructor(private deps: BookingAuditActionServiceRegistryDeps) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const services: Array<[BookingAuditAction, IAuditActionService]> = [ + const services: Array<[BookingAuditAction, IAuditActionService]> = [ ["CREATED", new CreatedAuditActionService()], ["CANCELLED", new CancelledAuditActionService()], ["RESCHEDULED", new RescheduledAuditActionService()], @@ -65,6 +66,8 @@ export class BookingAuditActionServiceRegistry { ["REASSIGNMENT", new ReassignmentAuditActionService(deps.userRepository)], ["LOCATION_CHANGED", new LocationChangedAuditActionService()], ["ATTENDEE_NO_SHOW_UPDATED", new AttendeeNoShowUpdatedAuditActionService()], + ["SEAT_BOOKED", new SeatBookedAuditActionService()], + ["SEAT_RESCHEDULED", new SeatRescheduledAuditActionService()], ]; this.actionServices = new Map(services); } @@ -73,11 +76,10 @@ export class BookingAuditActionServiceRegistry { * Get Action Service - Returns the appropriate action service for the given action type * * @param action - The booking audit action type - * @returns The corresponding action service instance + * @returns The corresponding action service instance with proper typing * @throws Error if no service is found for the action */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getActionService(action: BookingAuditAction): IAuditActionService { + getActionService(action: BookingAuditAction): IAuditActionService { const service = this.actionServices.get(action); if (!service) { throw new Error(`No action service found for: ${action}`); diff --git a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts index a2acfb1192..d54a5bd7eb 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { Actor } from "../../../bookings/lib/types/actor"; -import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask"; +import type { ActionSource } from "../types/actionSource"; import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService"; import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService"; @@ -14,6 +14,8 @@ import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActi import { RejectedAuditActionService } from "../actions/RejectedAuditActionService"; import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService"; import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; +import { SeatBookedAuditActionService } from "../actions/SeatBookedAuditActionService"; +import { SeatRescheduledAuditActionService } from "../actions/SeatRescheduledAuditActionService"; /** * BookingAuditProducerService Interface @@ -29,95 +31,158 @@ import { RescheduledAuditActionService } from "../actions/RescheduledAuditAction * - BookingAuditTriggerProducerService: (Future) Uses Trigger.dev for cloud jobs */ export interface BookingAuditProducerService { - /** - * Queue Audit - Legacy method for backwards compatibility with existing code - * - * @deprecated Use specialized methods (queueCreatedAudit, queueCancelledAudit, etc.) instead - */ - queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise; + queueCreatedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + data: z.infer; + }): Promise; - queueCreatedAudit(params: { - bookingUid: string; - actor: Actor; - organizationId: number | null; - data: z.infer; - }): Promise; + queueRescheduledAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | 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; + source: ActionSource; + operationId?: string | null; + data: z.infer; + }): Promise; - queueAttendeeNoShowUpdatedAudit(params: { - bookingUid: string; - actor: Actor; - organizationId: number | null; - data: z.infer; - }): Promise; + queueSeatBookedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + data: z.infer; + }): Promise; + + queueSeatRescheduledAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + data: z.infer; + }): Promise; + + /** + * Queues a bulk accepted audit task for multiple bookings + */ + queueBulkAcceptedAudit(params: { + bookings: Array<{ + bookingUid: string; + data: z.infer; + }>; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + }): Promise; + + /** + * Queues a bulk cancelled audit task for multiple bookings + */ + queueBulkCancelledAudit(params: { + bookings: Array<{ + bookingUid: string; + data: z.infer; + }>; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + }): Promise; } diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts index 26027edb85..f0417c9409 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts @@ -4,13 +4,18 @@ import logger from "@calcom/lib/logger"; import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface"; import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; -import type { Actor } from "../../../bookings/lib/types/actor"; -import type { BookingAuditTaskBasePayload } from "../types/bookingAuditTask"; -import { BookingAuditTaskBaseSchema } from "../types/bookingAuditTask"; -import { BookingAuditActionServiceRegistry, type AuditActionData } from "./BookingAuditActionServiceRegistry"; -import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction } from "../repository/IBookingAuditRepository"; +import type { PiiFreeActor } from "../../../bookings/lib/types/actor"; +import type { + SingleBookingAuditTaskConsumerPayload, + BulkBookingAuditTaskConsumerPayload, + BookingAuditTaskConsumerPayload +} from "../types/bookingAuditTask"; +import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry"; +import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction, BookingAuditCreateInput } from "../repository/IBookingAuditRepository"; import type { IAuditActorRepository } from "../repository/IAuditActorRepository"; +import type { ActionSource } from "../types/actionSource"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { Task } from "@calcom/features/tasker/repository"; interface BookingAuditTaskConsumerDeps { bookingAuditRepository: IBookingAuditRepository; @@ -24,6 +29,8 @@ type CreateBookingAuditInput = { actorId: string; type: BookingAuditType; action: BookingAuditAction; + source: ActionSource; + operationId: string; data: JsonValue; timestamp: Date; // Required: actual time of the booking change (business event) }; @@ -74,58 +81,53 @@ export class BookingAuditTaskConsumer { } /** - * Process Audit Task - Entry point for task handler - * - * This method handles: - * 1. Base schema validation (parses data as unknown) - * 2. Feature flag checks - * 3. Action-specific data validation and migration - * 4. Routing to appropriate action handlers - * - * Two-Stage Parsing: - * - First validates base fields with lean schema (data as unknown) - * - Then validates data with action-specific schema via action service - * - This avoids large discriminated unions for better TypeScript performance - * - * @param payload - The booking audit task payload (unknown type, will be validated) - * @param taskId - Optional task ID for updating migrated payload in DB - * @returns Promise that resolves when processing is complete + * Process single booking Audit Task */ - async processAuditTask(payload: unknown, taskId: string): Promise { - // Step 1: Parse with lean base schema (data as unknown) - const parseResult = BookingAuditTaskBaseSchema.safeParse(payload); + async processAuditTask(payload: SingleBookingAuditTaskConsumerPayload, taskId: string): Promise { + const { action, bookingUid, actor, organizationId, data, timestamp, source, operationId } = payload; - if (!parseResult.success) { - const errorMsg = `Invalid booking audit payload: ${safeStringify(parseResult.error.errors)}`; - logger.error(errorMsg); - throw new Error(errorMsg); - } - - const validatedPayload = parseResult.data; - const { action, bookingUid, actor, organizationId, data, timestamp } = validatedPayload; - - // Skip processing for non-organization bookings - if (organizationId === null) { - logger.info( - `Skipping audit for non-organization booking: action=${action}, bookingUid=${bookingUid}` - ); + if (!await this.shouldProcessAudit({ + organizationId, + action, + bookingUids: [bookingUid], + })) { return; } - const isFeatureEnabled = await this.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit"); + const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload, taskId }); - if (!isFeatureEnabled) { - logger.info( - `booking-audit feature is disabled for organization, skipping audit processing: action=${action}, bookingUid=${bookingUid}, organizationId=${organizationId}` - ); + await this.onBookingAction({ bookingUid, actor, action, source, operationId, data: dataInLatestFormat, timestamp }); + } + + /** + * Process Bulk bookings Audit Task + */ + async processBulkAuditTask(payload: BulkBookingAuditTaskConsumerPayload, taskId: string): Promise { + const { bookings, action, actor, organizationId, timestamp, source, operationId } = payload; + + if (!await this.shouldProcessAudit({ + organizationId, + action, + bookingUids: bookings.map((booking) => booking.bookingUid), + })) { return; } - // Step 2: Validate and migrate data with action-specific schema - const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload: validatedPayload, taskId }); + const migratedBookings = await this.bulkMigrateIfNeeded({ + bookings, + action, + payload, + taskId, + }); - // dataInLatestFormat is validated by action-specific schema in migrateIfNeeded - await this.onBookingAction({ bookingUid, actor, action, data: dataInLatestFormat as AuditActionData, timestamp }); + await this.onBulkBookingActions({ + bookings: migratedBookings, + actor, + action, + source, + operationId, + timestamp, + }); } /** @@ -142,7 +144,7 @@ export class BookingAuditTaskConsumer { private async migrateIfNeeded(params: { action: BookingAuditAction; data: unknown; - payload: BookingAuditTaskBasePayload; + payload: SingleBookingAuditTaskConsumerPayload; taskId: string; }) { const { action, data, payload, taskId } = params; @@ -173,33 +175,124 @@ export class BookingAuditTaskConsumer { * @param taskId - Task ID (required for DB update) */ private async updateTaskPayload( - payload: BookingAuditTaskBasePayload, + payload: BookingAuditTaskConsumerPayload, latestData: unknown, taskId: string ): Promise { try { - const { Task } = await import("@calcom/features/tasker/repository"); - const updatedPayload = { ...payload, data: latestData }; - await Task.updatePayload(taskId, JSON.stringify(updatedPayload)); logger.info( `Successfully updated task payload in DB: taskId=${taskId}, action=${payload.action}` ); } catch (error) { - // Log error but don't fail the task - migration happened in memory - logger.error( + // Warning because nothing functionally failed, we have a risk in future that if we completely remove a version support, the particular task if retried will fail with a schema error. + logger.warn( `Failed to update task payload in DB: taskId=${taskId}, error=${safeStringify(error)}` ); } } + /** + * Bulk Migrate If Needed - Migrates all bookings in a bulk operation + * + * Since all bookings in a bulk operation share the same action type, + * they all use the same action service and schema version. + * If any booking is migrated, updates the DB task payload for retry consistency. + * + * @param params - Bookings, action, payload, and task ID + * @returns Array of migrated bookings with latest data + */ + private async bulkMigrateIfNeeded(params: { + bookings: BulkBookingAuditTaskConsumerPayload["bookings"]; + action: BookingAuditAction; + payload: BulkBookingAuditTaskConsumerPayload; + taskId: string; + }): Promise }>> { + const { bookings, action, payload, taskId } = params; + const actionService = this.actionServiceRegistry.getActionService(action); + + let anyMigrated = false; + const migratedBookings = await Promise.all( + bookings.map(async (booking) => { + const migrationResult = actionService.migrateToLatest(booking.data); + if (migrationResult.isMigrated) { + anyMigrated = true; + } + return { + bookingUid: booking.bookingUid, + data: migrationResult.latestData, + }; + }) + ); + + if (anyMigrated) { + logger.info(`Schema migration performed for bulk audit: action=${action}`); + await this.updateBulkTaskPayload(payload, migratedBookings, taskId); + } + + return migratedBookings; + } + + private async updateBulkTaskPayload( + payload: BulkBookingAuditTaskConsumerPayload, + migratedBookings: Array<{ bookingUid: string; data: Record }>, + taskId: string + ): Promise { + try { + const updatedPayload = { ...payload, bookings: migratedBookings }; + await Task.updatePayload(taskId, JSON.stringify(updatedPayload)); + + logger.info( + `Successfully updated bulk task payload in DB: taskId=${taskId}, action=${payload.action}` + ); + } catch (error) { + // Warning because nothing functionally failed, we have a risk in future that if we completely remove a version support, the particular task if retried will fail with a schema error. + logger.warn( + `Failed to update bulk task payload in DB: taskId=${taskId}, error=${safeStringify(error)}` + ); + } + } + + /** + * Check if audit should be processed based on organization and feature flag + * + * @param params - Organization ID, action, and context for logging + * @returns true if audit should be processed, false otherwise + */ + private async shouldProcessAudit(params: { + organizationId: number | null; + action: BookingAuditAction; + bookingUids: string[]; + }): Promise { + const { organizationId, action, bookingUids } = params; + + if (organizationId === null) { + logger.info(`Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}`); + return false; + } + + const isFeatureEnabled = await this.featuresRepository.checkIfTeamHasFeature( + organizationId, + "booking-audit" + ); + + if (!isFeatureEnabled) { + logger.info( + `booking-audit feature is disabled for organization: action=${action}, bookingUids=${bookingUids.join(",")}, organizationId=${organizationId}` + ); + return false; + } + + return true; + } + /** * Resolves an Actor to an actor ID in the AuditActor table * Handles different actor types appropriately (upsert, lookup, or direct ID) */ - private async resolveActorId(actor: Actor): Promise { + private async resolveActorId(actor: PiiFreeActor): Promise { switch (actor.identifiedBy) { case "id": return actor.id; @@ -208,20 +301,9 @@ export class BookingAuditTaskConsumer { return userActor.id; } case "attendee": { - const attendeeActor = await this.auditActorRepository.findByAttendeeId(actor.attendeeId); - if (!attendeeActor) { - throw new Error(`Attendee actor not found for attendeeId: ${actor.attendeeId}`); - } + const attendeeActor = await this.auditActorRepository.createIfNotExistsAttendeeActor({ attendeeId: actor.attendeeId }); return attendeeActor.id; } - case "guest": { - const guestActor = await this.auditActorRepository.createIfNotExistsGuestActor( - actor.email ?? null, - actor.name ?? null, - actor.phone ?? null - ); - return guestActor.id; - } } } @@ -235,6 +317,7 @@ export class BookingAuditTaskConsumer { actorId: input.actorId, type: input.type, action: input.action, + source: input.source, timestamp: input.timestamp, })); @@ -243,7 +326,9 @@ export class BookingAuditTaskConsumer { actorId: input.actorId, type: input.type, action: input.action, + source: input.source, timestamp: input.timestamp, + operationId: input.operationId, data: input.data ?? null, }); } @@ -262,7 +347,7 @@ export class BookingAuditTaskConsumer { private getRecordType(params: { action: BookingAuditAction }): BookingAuditType { const { action } = params; - + switch (action) { case "CREATED": return "RECORD_CREATED"; @@ -279,6 +364,8 @@ export class BookingAuditTaskConsumer { case "LOCATION_CHANGED": case "HOST_NO_SHOW_UPDATED": case "ATTENDEE_NO_SHOW_UPDATED": + case "SEAT_BOOKED": + case "SEAT_RESCHEDULED": return "RECORD_UPDATED"; // PENDING and AWAITING_HOST are not implemented as they represent initial states @@ -288,14 +375,51 @@ export class BookingAuditTaskConsumer { } } + private async onBulkBookingActions(params: { + bookings: Array<{ bookingUid: string; data: Record }>; + actor: PiiFreeActor; + action: BookingAuditAction; + source: ActionSource; + operationId: string; + timestamp: number; + }): Promise { + const { bookings, actor, action, source, operationId, timestamp } = params; + + const actorId = await this.resolveActorId(actor); + const recordType = this.getRecordType({ action }); + const actionService = this.actionServiceRegistry.getActionService(action); + + const auditRecordsToCreate: BookingAuditCreateInput[] = bookings.map((booking) => { + const versionedData = actionService.getVersionedData(booking.data); + return { + bookingUid: booking.bookingUid, + actorId, + type: recordType, + action, + source, + operationId, + data: versionedData as JsonValue, + timestamp: new Date(timestamp), + }; + }); + + await this.bookingAuditRepository.createMany(auditRecordsToCreate); + + logger.info( + `Successfully created ${auditRecordsToCreate.length} bulk audit records: action=${action}, operationId=${operationId}` + ); + } + async onBookingAction(params: { bookingUid: string; - actor: Actor; + actor: PiiFreeActor; action: BookingAuditAction; - data: AuditActionData; + source: ActionSource; + operationId: string; + data: Record; timestamp: number; }): Promise { - const { bookingUid, actor, action, data, timestamp } = params; + const { bookingUid, actor, action, source, operationId, data, timestamp } = params; const actionService = this.actionServiceRegistry.getActionService(action); const versionedData = actionService.getVersionedData(data); const actorId = await this.resolveActorId(actor); @@ -306,6 +430,8 @@ export class BookingAuditTaskConsumer { actorId, type: recordType, action, + source, + operationId, // versionedData is { version: number; fields: unknown } which is JsonValue-compatible data: versionedData as JsonValue, timestamp: new Date(timestamp), diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts index 5fb391c76c..03cfb41863 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts @@ -1,11 +1,14 @@ import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; import type { Tasker } from "@calcom/features/tasker/tasker"; import { IS_PRODUCTION } from "@calcom/lib/constants"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; -import type { Actor } from "../../../bookings/lib/types/actor"; -import type { BookingAuditAction, BookingAuditTaskProducerActionData } from "../types/bookingAuditTask"; +import type { BookingAuditAction } from "../types/bookingAuditTask"; +import type { ActionSource } from "../types/actionSource"; +import { makeActorById, type PiiFreeActor, type Actor, buildActorEmail } from "../../../bookings/lib/types/actor"; +import type { IAuditActorRepository } from "../repository/IAuditActorRepository"; import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService"; import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService"; @@ -18,11 +21,14 @@ import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActi import { RejectedAuditActionService } from "../actions/RejectedAuditActionService"; import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService"; import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; +import { SeatBookedAuditActionService } from "../actions/SeatBookedAuditActionService"; +import { SeatRescheduledAuditActionService } from "../actions/SeatRescheduledAuditActionService"; import type { BookingAuditProducerService } from "./BookingAuditProducerService.interface"; interface BookingAuditTaskerProducerServiceDeps { tasker: Tasker; log: ISimpleLogger; + auditActorRepository: IAuditActorRepository; } /** @@ -37,41 +43,60 @@ interface BookingAuditTaskerProducerServiceDeps { export class BookingAuditTaskerProducerService implements BookingAuditProducerService { private readonly tasker: Tasker; private readonly log: BookingAuditTaskerProducerServiceDeps["log"]; + private readonly auditActorRepository: IAuditActorRepository; constructor(private readonly deps: BookingAuditTaskerProducerServiceDeps) { this.tasker = deps.tasker; this.log = deps.log; + this.auditActorRepository = deps.auditActorRepository; } - /** - * Queue Audit - Legacy method for backwards compatibility - * - * @deprecated Use specialized methods (queueCreatedAudit, queueCancelledAudit, etc.) instead - */ - async queueAudit( - bookingUid: string, - actor: Actor, - organizationId: number | null, - actionData: BookingAuditTaskProducerActionData - ): Promise { - await this.queueTask({ - bookingUid, - actor, - organizationId, - action: actionData.action, - data: actionData.data, + private async getPIIFreeBookingAuditActor(params: { + actor: Actor; + }): Promise { + const { actor } = params; + + if (actor.identifiedBy === "user" || actor.identifiedBy === "attendee" || actor.identifiedBy === "id") { + return actor; + } + + if (actor.identifiedBy === "app") { + const piiFreeActor = await this.auditActorRepository.createIfNotExistsAppActor({ + credentialId: actor.credentialId, + }); + return makeActorById(piiFreeActor.id); + } + + if (actor.identifiedBy === "appSlug") { + const email = buildActorEmail({ identifier: actor.appSlug, actorType: "app" }); + const piiFreeActor = await this.auditActorRepository.createIfNotExistsAppActor({ + email, + name: actor.name, + }); + return makeActorById(piiFreeActor.id); + } + + // Must be guest actor at this point + const piiFreeActor = await this.auditActorRepository.createIfNotExistsGuestActor({ + email: actor.email, + name: actor.name ?? null, + phone: null, }); + return makeActorById(piiFreeActor.id); } /** * Internal helper to queue audit task to Tasker * @param params.action - Must be a valid BookingAuditAction value (TYPE from action services are string-typed) + * @param params.operationId - Optional operation ID for correlating bulk operations. If null, will be auto-generated. */ private async queueTask(params: { bookingUid: string; actor: Actor; organizationId: number | null; action: string; + source: ActionSource; + operationId?: string | null; data: unknown; }): Promise { // Skip queueing for non-organization bookings @@ -82,13 +107,21 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe return; } try { - // Cast action to BookingAuditAction since action service TYPE constants are typed as string - await this.tasker.create("bookingAudit", { - bookingUid: params.bookingUid, + const piiFreeActor = await this.getPIIFreeBookingAuditActor({ actor: params.actor, + }); + + const operationId = params.operationId ?? uuidv4(); + + await this.tasker.create("bookingAudit", { + isBulk: false, + bookingUid: params.bookingUid, + actor: piiFreeActor, organizationId: params.organizationId, timestamp: Date.now(), action: params.action as BookingAuditAction, + source: params.source, + operationId, data: params.data, }); } catch (error) { @@ -100,6 +133,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -112,6 +147,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -124,6 +161,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -136,6 +175,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -148,6 +189,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -160,6 +203,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -172,6 +217,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -184,6 +231,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -196,6 +245,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -208,6 +259,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -220,6 +273,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -232,6 +287,8 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe bookingUid: string; actor: Actor; organizationId: number | null; + source: ActionSource; + operationId?: string | null; data: z.infer; }): Promise { await this.queueTask({ @@ -239,4 +296,104 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe action: AttendeeNoShowUpdatedAuditActionService.TYPE, }); } + + async queueSeatBookedAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: SeatBookedAuditActionService.TYPE, + }); + } + + async queueSeatRescheduledAudit(params: { + bookingUid: string; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + data: z.infer; + }): Promise { + await this.queueTask({ + ...params, + action: SeatRescheduledAuditActionService.TYPE, + }); + } + + private async queueBulkTask(params: { + bookings: Array<{ + bookingUid: string; + data: unknown; + }>; + actor: Actor; + organizationId: number | null; + action: string; + source: ActionSource; + operationId?: string | null; + }): Promise { + // Skip queueing for non-organization bookings + if (params.organizationId === null) { + return; + } + if (IS_PRODUCTION) { + return; + } + try { + const piiFreeActor = await this.getPIIFreeBookingAuditActor({ + actor: params.actor, + }); + + const operationId = params.operationId ?? uuidv4(); + + await this.tasker.create("bookingAudit", { + isBulk: true, + bookings: params.bookings, + actor: piiFreeActor, + organizationId: params.organizationId, + timestamp: Date.now(), + action: params.action as BookingAuditAction, + source: params.source, + operationId, + }); + } catch (error) { + this.log.error(`Error while queueing bulk ${params.action} audit`, safeStringify(error)); + } + } + + async queueBulkAcceptedAudit(params: { + bookings: Array<{ + bookingUid: string; + data: z.infer; + }>; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + }): Promise { + await this.queueBulkTask({ + ...params, + action: AcceptedAuditActionService.TYPE, + }); + } + + async queueBulkCancelledAudit(params: { + bookings: Array<{ + bookingUid: string; + data: z.infer; + }>; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + }): Promise { + await this.queueBulkTask({ + ...params, + action: CancelledAuditActionService.TYPE, + }); + } } diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts index 4f28184a01..165afacb21 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -1,19 +1,26 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; - +import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry"; +import { BookingAuditAccessService } from "./BookingAuditAccessService"; import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository"; import type { AuditActorType } from "../repository/IAuditActorRepository"; import type { TranslationWithParams } from "../actions/IAuditActionService"; +import type { ActionSource } from "../types/actionSource"; import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; -import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { getAppNameFromSlug } from "../getAppNameFromSlug"; interface BookingAuditViewerServiceDeps { bookingAuditRepository: IBookingAuditRepository; userRepository: UserRepository; bookingRepository: BookingRepository; + membershipRepository: MembershipRepository; + attendeeRepository: IAttendeeRepository; log: ISimpleLogger; + credentialRepository: CredentialRepository; } type EnrichedAuditLog = { @@ -23,9 +30,11 @@ type EnrichedAuditLog = { action: BookingAuditAction; timestamp: string; createdAt: string; - data: Record | null; + source: ActionSource; + operationId: string; + displayJson?: Record | null; actionDisplayTitle: TranslationWithParams; - displayFields?: Array<{ labelKey: string; valueKey: string }>; + displayFields?: Array<{ labelKey: string; valueKey: string }> | null; actor: { id: string; type: AuditActorType; @@ -47,16 +56,26 @@ export class BookingAuditViewerService { private readonly bookingAuditRepository: IBookingAuditRepository; private readonly userRepository: UserRepository; private readonly bookingRepository: BookingRepository; + private readonly membershipRepository: MembershipRepository; + private readonly attendeeRepository: IAttendeeRepository; + private readonly credentialRepository: CredentialRepository; private readonly rescheduledAuditActionService: RescheduledAuditActionService; + private readonly accessService: BookingAuditAccessService; private readonly log: BookingAuditViewerServiceDeps["log"]; constructor(private readonly deps: BookingAuditViewerServiceDeps) { this.bookingAuditRepository = deps.bookingAuditRepository; this.userRepository = deps.userRepository; this.bookingRepository = deps.bookingRepository; + this.membershipRepository = deps.membershipRepository; + this.attendeeRepository = deps.attendeeRepository; + this.credentialRepository = deps.credentialRepository; this.log = deps.log; this.rescheduledAuditActionService = new RescheduledAuditActionService(); - + this.accessService = new BookingAuditAccessService({ + bookingRepository: this.bookingRepository, + membershipRepository: this.membershipRepository, + }); this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository }); } @@ -68,21 +87,24 @@ export class BookingAuditViewerService { * fetches the last RESCHEDULED log from the previous booking and includes it * as the first log entry with "rescheduled from" context. */ - async getAuditLogsForBooking( - bookingUid: string, - _userId: number, - _userEmail: string, - userTimeZone?: string - ): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> { - await this.checkPermissions(); + async getAuditLogsForBooking(params: { + bookingUid: string; + userId: number; + userEmail: string; + userTimeZone: string; + organizationId: number | null; + }): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> { + const { bookingUid, userId, userTimeZone, organizationId } = params; + await this.accessService.assertPermissions({ + bookingUid, + userId, + organizationId + }); const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid); - // Default to UTC if no timezone provided (backwards compatibility) - const timezone = userTimeZone ?? "UTC"; - const enrichedAuditLogs = await Promise.all( - auditLogs.map((log) => this.enrichAuditLog(log, timezone)) + auditLogs.map((log) => this.enrichAuditLog(log, userTimeZone)) ); const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid); @@ -92,7 +114,7 @@ export class BookingAuditViewerService { const rescheduledFromLog = await this.buildRescheduledFromLog({ fromRescheduleUid, currentBookingUid: bookingUid, - userTimeZone: timezone, + userTimeZone, }); if (rescheduledFromLog) { // Add the rescheduled log from the previous booking as the first entry @@ -102,7 +124,7 @@ export class BookingAuditViewerService { } return { - bookingUid, + bookingUid: params.bookingUid, auditLogs: enrichedAuditLogs, }; } @@ -118,15 +140,13 @@ export class BookingAuditViewerService { const actionDisplayTitle = await actionService.getDisplayTitle({ storedData: parsedData, userTimeZone }); - // Get display data - use custom getDisplayJson if available, otherwise use raw fields - const displayData = (actionService.getDisplayJson + const displayJson = actionService.getDisplayJson ? actionService.getDisplayJson({ storedData: parsedData, userTimeZone }) - : parsedData.fields) as Record | null; + : null; - // Get display fields - use custom getDisplayFields if available const displayFields = actionService.getDisplayFields ? actionService.getDisplayFields(parsedData) - : undefined; + : null; return { id: log.id, @@ -135,7 +155,9 @@ export class BookingAuditViewerService { action: log.action, timestamp: log.timestamp.toISOString(), createdAt: log.createdAt.toISOString(), - data: displayData, + source: log.source, + operationId: log.operationId, + displayJson, actionDisplayTitle, displayFields, actor: { @@ -184,37 +206,29 @@ export class BookingAuditViewerService { const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone); const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data); - // Transform the display data to show "rescheduled from" instead of "rescheduled to" + // Transform the display JSON to show "rescheduled from" instead of "rescheduled to" // by replacing rescheduledToUid with rescheduledFromUid - const transformedData = { - ...enrichedLog.data, - rescheduledFromUid: fromRescheduleUid, - }; + const transformedDisplayJson = enrichedLog.displayJson + ? { + ...enrichedLog.displayJson, + rescheduledFromUid: fromRescheduleUid, + } + : undefined; return { ...enrichedLog, // Override bookingUid to associate with the current booking being viewed bookingUid: currentBookingUid, - data: transformedData, + displayJson: transformedDisplayJson, // Use a different translation key to show "Rescheduled from" instead of "Rescheduled" actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({ fromRescheduleUid, userTimeZone, - parsedData, + storedData: parsedData, }), }; } - /** - * Check if user has permission to view audit logs for a booking - */ - private async checkPermissions(): Promise { - // TODO: Implement permission check - if (IS_PRODUCTION) { - throw new Error("Permission check is not implemented for production environments"); - } - } - /** * Enrich actor information with user details if userUuid exists */ @@ -223,54 +237,85 @@ export class BookingAuditViewerService { displayEmail: string | null; displayAvatar: string | null; }> { - // SYSTEM actor - if (actor.type === "SYSTEM") { - return { - displayName: "Cal.com", - displayEmail: null, - displayAvatar: null, - }; - } - - // GUEST actor - use name or default - if (actor.type === "GUEST") { - return { - displayName: actor.name || "Guest", - displayEmail: null, - displayAvatar: null, - }; - } - - // ATTENDEE actor - use name or default - if (actor.type === "ATTENDEE") { - return { - displayName: actor.name || "Attendee", - displayEmail: null, - displayAvatar: null, - }; - } - - // USER actor - lookup from User table and include avatar - if (actor.type === "USER") { - if (!actor.userUuid) { - throw new Error("User UUID is required for USER actor"); - } - const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid }); - if (actorUser) { + switch (actor.type) { + case "SYSTEM": return { - displayName: actorUser.name || actorUser.email, - displayEmail: actorUser.email, - displayAvatar: actorUser.avatarUrl || null, + displayName: "Cal.com", + displayEmail: null, + displayAvatar: null, + }; + + case "GUEST": + return { + displayName: actor.name || "Guest", + displayEmail: null, + displayAvatar: null, + }; + + case "APP": { + if (actor.credentialId) { + const credential = await this.deps.credentialRepository.findByCredentialId(actor.credentialId); + if (credential) { + return { + displayName: getAppNameFromSlug({ appSlug: credential.appId }), + displayEmail: null, + displayAvatar: null, + }; + } else { + return { + // Expect that on Credential deletion name would have been set + displayName: actor.name ?? "Deleted App", + displayEmail: null, + displayAvatar: null, + }; + } + } + // We allow creating App actor without credentialId + return { + displayName: actor.name ?? "Unknown App", + // We don't want to show email for App actor as that is an internal email with the purpose of giving uniqueness to each app actor + displayEmail: null, + displayAvatar: null, }; } - return { - displayName: "Deleted User", - displayEmail: null, - displayAvatar: null, + + case "ATTENDEE": { + if (!actor.attendeeId) { + throw new Error("Attendee ID is required for ATTENDEE actor"); + } + const attendee = await this.attendeeRepository.findById(actor.attendeeId); + if (attendee) { + return { + displayName: attendee.name || attendee.email, + displayEmail: attendee.email, + displayAvatar: null, + }; + } + return { + displayName: "Deleted Attendee", + displayEmail: null, + displayAvatar: null, + }; + } + + case "USER": { + if (!actor.userUuid) { + throw new Error("User UUID is required for USER actor"); + } + const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid }); + if (actorUser) { + return { + displayName: actorUser.name || actorUser.email, + displayEmail: actorUser.email, + displayAvatar: actorUser.avatarUrl || null, + }; + } + return { + displayName: "Deleted User", + displayEmail: null, + displayAvatar: null, + }; } } - - // Satisfying Typescript - throw new Error(`Unknown actor type: ${actor.type}`); } } diff --git a/packages/features/booking-audit/lib/service/__tests__/BookingAuditAccessService.test.ts b/packages/features/booking-audit/lib/service/__tests__/BookingAuditAccessService.test.ts new file mode 100644 index 0000000000..ca04e18e7d --- /dev/null +++ b/packages/features/booking-audit/lib/service/__tests__/BookingAuditAccessService.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest"; + +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { BookingAuditAccessService, BookingAuditErrorCode, BookingAuditPermissionError } from "../BookingAuditAccessService"; + +vi.mock("@calcom/features/pbac/services/permission-check.service"); +vi.mock("@calcom/features/bookings/repositories/BookingRepository"); +vi.mock("@calcom/features/membership/repositories/MembershipRepository"); +const DB = { + bookings: {} as Record, + memberships: {} as Record, +} + +const createMockTeamBooking = (overrides: { + bookingUid: string; + userId?: number; + teamId?: number | null; + parentTeamId?: number; +}) => { + const booking = { + uid: overrides.bookingUid, + userId: overrides?.userId ?? 456, + eventType: { + teamId: (overrides && "teamId" in overrides ? overrides.teamId : overrides?.teamId ?? 100) ?? null, + parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null, + hosts: [], + users: [] + }, + user: { + id: overrides?.userId ?? 456, + email: "test@example.com", + }, + attendees: [], + } + DB.bookings[booking.uid] = booking; + return booking; +} + +const createMockPersonalBooking = (overrides: { userId?: number; bookingUid: string }) => { + const booking = { + uid: overrides.bookingUid, + userId: overrides?.userId ?? 456, + eventType: { + teamId: null, + parent: null, + hosts: [], + users: [], + }, + attendees: [], + user: { + id: overrides?.userId ?? 456, + email: "test@example.com", + }, + }; + DB.bookings[booking.uid] = booking; + return booking; +} + +const createMockMembership = ({ userId, teamId }: { userId: number; teamId: number }) => { + const key = `${userId}-${teamId}`; + DB.memberships[key] = true; +}; + +type MockPermissionCheckService = { + checkPermission: Mock; +} + +const provideReadTeamAuditLogsPermission = ({ mockPermissionCheckService, value, targetUserId, targetTeamId }: { mockPermissionCheckService: MockPermissionCheckService, value: boolean, targetUserId: number, targetTeamId: number }) => { + mockPermissionCheckService.checkPermission.mockImplementation(({ userId, teamId, permission, _fallbackRoles }) => { + if (permission === "booking.readTeamAuditLogs" && userId === targetUserId && teamId === targetTeamId) { + return Promise.resolve(value); + } + return Promise.resolve(false); + }); +} + +const provideReadOrgAuditLogsPermission = ({ mockPermissionCheckService, value, targetUserId, targetTeamId }: { mockPermissionCheckService: MockPermissionCheckService, value: boolean, targetUserId: number, targetTeamId: number }) => { + mockPermissionCheckService.checkPermission.mockImplementation(({ userId, teamId, permission, _fallbackRoles }) => { + if (permission === "booking.readOrgAuditLogs" && userId === targetUserId && teamId === targetTeamId) { + return Promise.resolve(value); + } + return Promise.resolve(false); + }); +} + +const mockBookingRepository: { + findByUidIncludeEventType: Mock; +} = { + findByUidIncludeEventType: vi.fn().mockImplementation(({ bookingUid }) => { + return Promise.resolve(DB.bookings[bookingUid] ?? null); + }), +}; + +const mockMembershipRepository: { + hasMembership: Mock; +} = { + hasMembership: vi.fn().mockImplementation(({ userId, teamId }) => { + const key = `${userId}-${teamId}`; + return Promise.resolve(DB.memberships[key] ?? false); + }), +}; + +describe("BookingAuditAccessService - Permission Checks", () => { + let service: BookingAuditAccessService; + let mockPermissionCheckService: MockPermissionCheckService; + + beforeEach(() => { + vi.clearAllMocks(); + + // Clear in-memory DB + DB.bookings = {}; + DB.memberships = {}; + + mockPermissionCheckService = { + checkPermission: vi.fn(), + }; + + vi.mocked(BookingRepository).mockImplementation(() => mockBookingRepository as unknown as BookingRepository); + vi.mocked(MembershipRepository).mockImplementation(() => mockMembershipRepository as unknown as MembershipRepository); + vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as unknown as PermissionCheckService); + + service = new BookingAuditAccessService({ + bookingRepository: mockBookingRepository as unknown as BookingRepository, + membershipRepository: mockMembershipRepository as unknown as MembershipRepository, + }); + }); + + describe("assertPermissions - Team Bookings", () => { + it("should grant access when user has booking.readTeamAuditLogs permission for the booking's team", async () => { + const bookingUid = "test-booking-uid"; + const userId = 123; + const teamId = 100; + createMockTeamBooking({ teamId, bookingUid }); + provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: teamId }); + + await expect(service.assertPermissions({ bookingUid, userId, organizationId: 200 })).resolves.not.toThrow(); + }); + + it("should throw PERMISSION_DENIED error when user lacks booking.readTeamAuditLogs permission and also doesn't even have a membership in the organization for the booking's team", async () => { + const bookingUid = "test-booking-uid"; + const userId = 123; + const teamId = 100; + const organizationId = 200; + createMockTeamBooking({ teamId, bookingUid, userId: 456 }); + createMockMembership({ userId: 456, teamId: organizationId }); + provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: teamId }); + provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: organizationId }); + const promise = service.assertPermissions({ bookingUid, userId, organizationId }); + await expect(promise).rejects.toThrow(BookingAuditPermissionError); + await expect(promise).rejects.toThrow(BookingAuditErrorCode.PERMISSION_DENIED); + }); + }); + + describe("assertPermissions - Organization Bookings (Personal)", () => { + it("should grant access to personal bookings when user is an org member and has booking.readOrgAuditLogs permission for the organization", async () => { + const bookingUid = "test-booking-uid"; + const userId = 123; + const organizationId = 200; + createMockPersonalBooking({ userId: 456, bookingUid }); + createMockMembership({ userId: 456, teamId: organizationId }); + provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: organizationId }); + + await service.assertPermissions({ bookingUid, userId, organizationId }); + + expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({ + userId, + teamId: organizationId, + permission: "booking.readOrgAuditLogs", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + }); + }); + + describe("assertPermissions - Managed Events", () => { + it("should grant access to managed event's booking when user has booking.readTeamAuditLogs permission on parent event's team", async () => { + const bookingUid = "test-booking-uid"; + const userId = 123; + const parentTeamId = 500; + createMockTeamBooking({ teamId: null, parentTeamId, bookingUid }); + provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: parentTeamId }); + + await service.assertPermissions({ bookingUid, userId, organizationId: 200 }); + + expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({ + userId, + teamId: parentTeamId, + permission: "booking.readTeamAuditLogs", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + }); + + it("should throw error when user lacks booking.readTeamAuditLogs permission on parent team", async () => { + const bookingUid = "test-booking-uid"; + const userId = 123; + const parentTeamId = 500; + const organizationId = 200; + createMockTeamBooking({ teamId: null, parentTeamId, bookingUid, userId: 456 }); + createMockMembership({ userId: 456, teamId: organizationId }); + provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: parentTeamId }); + provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: organizationId }); + + await expect(service.assertPermissions({ bookingUid, userId, organizationId })).rejects.toThrow(BookingAuditPermissionError); + }); + }); + + describe("assertPermissions - Edge Cases", () => { + it("should throw error when organizationId is null", async () => { + await expect(service.assertPermissions({ bookingUid: "test-booking-uid", userId: 123, organizationId: null })).rejects.toThrow(BookingAuditPermissionError); + await expect(service.assertPermissions({ bookingUid: "test-booking-uid", userId: 123, organizationId: null })).rejects.toThrow(BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED); + }); + + it("should throw error when booking not found", async () => { + const bookingUid = "non-existent-booking-uid"; + // Don't create any booking in DB + + await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError); + await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED); + }); + + it("should throw error when booking has no userId", async () => { + const bookingUid = "test-booking-uid"; + const booking = { + uid: bookingUid, + userId: null, + eventType: { + teamId: null, + parent: null, + hosts: [], + users: [], + }, + attendees: [], + user: { + id: 456, + email: "test@example.com", + }, + }; + DB.bookings[bookingUid] = booking; + + await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError); + await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.BOOKING_HAS_NO_OWNER); + }); + + it("should throw error when booking owner is not member of organization", async () => { + const bookingUid = "test-booking-uid"; + createMockPersonalBooking({ userId: 456, bookingUid }); + // Don't create membership for userId 456 in organization 200 + + await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError); + await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION); + }); + }); +}); diff --git a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts new file mode 100644 index 0000000000..61449dcbff --- /dev/null +++ b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts @@ -0,0 +1,892 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest"; + +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; +import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; + +import { BookingAuditViewerService } from "../BookingAuditViewerService"; +import { BookingAuditPermissionError, BookingAuditErrorCode } from "../BookingAuditAccessService"; +import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../../repository/IBookingAuditRepository"; +import type { AuditActorType } from "../../repository/IAuditActorRepository"; + +vi.mock("@calcom/features/pbac/services/permission-check.service"); +vi.mock("@calcom/features/users/repositories/UserRepository"); +vi.mock("@calcom/features/bookings/repositories/BookingRepository"); +vi.mock("@calcom/features/membership/repositories/MembershipRepository"); + +const createMockTeamBooking = (overrides?: { + userId?: number; + teamId?: number | null; + parentTeamId?: number; +}) => ({ + userId: overrides?.userId ?? 456, + user: { + id: overrides?.userId ?? 456, + email: "test@example.com", + }, + eventType: { + teamId: (overrides && "teamId" in overrides ? overrides.teamId : overrides?.teamId ?? 100) ?? null, + parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null, + hosts: [], + users: [], + }, + attendees: [], +}); + + +const createMockAuditLog = ( + overrides?: Partial<{ + id: string; + bookingUid: string; + action: BookingAuditAction; + type: BookingAuditType; + timestamp: Date; + createdAt: Date; + updatedAt: Date; + data: unknown; + actorId: string; + actorType: AuditActorType; + actorUserUuid: string | null; + actorName: string | null; + }> +): BookingAuditWithActor => ({ + id: overrides?.id ?? "audit-log-1", + bookingUid: overrides?.bookingUid ?? "booking-uid-123", + action: overrides?.action ?? "CREATED", + type: overrides?.type ?? "RECORD_CREATED", + timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"), + createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"), + updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"), + actorId: overrides?.actorId ?? "actor-1", + data: overrides?.data ?? { version: 1, fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" } }, + source: "WEBAPP" as const, + operationId: "operation-id-123", + actor: { + id: overrides?.actorId ?? "actor-1", + type: overrides?.actorType ?? "USER" as const, + userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as string | null, + attendeeId: null, + name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null, + createdAt: new Date("2024-01-01T00:00:00Z"), + }, +}); + +const createMockUser = (overrides?: Partial<{ id: number; name: string | null; email: string; avatarUrl: string | null }>) => ({ + id: overrides?.id ?? 123, + name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null, + email: overrides?.email ?? "john@example.com", + avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null, +}); + +describe("BookingAuditViewerService - Integration Tests", () => { + let service: BookingAuditViewerService; + let mockBookingRepository: { + findByUidIncludeEventType: Mock; + getFromRescheduleUid: Mock; + }; + let mockUserRepository: { + getUserOrganizationAndTeams: Mock; + findByUuid: Mock; + }; + let mockBookingAuditRepository: { + create: Mock; + findAllForBooking: Mock; + findRescheduledLogsOfBooking: Mock; + }; + let mockMembershipRepository: { + hasMembership: Mock; + }; + let mockPermissionCheckService: { + checkPermission: Mock; + }; + let mockAttendeeRepository: { + findById: Mock; + }; + let mockLog: { + error: Mock; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockBookingRepository = { + findByUidIncludeEventType: vi.fn(), + getFromRescheduleUid: vi.fn(), + }; + + mockUserRepository = { + getUserOrganizationAndTeams: vi.fn(), + findByUuid: vi.fn(), + }; + + mockBookingAuditRepository = { + create: vi.fn(), + findAllForBooking: vi.fn().mockResolvedValue([]), + findRescheduledLogsOfBooking: vi.fn(), + }; + + mockMembershipRepository = { + hasMembership: vi.fn(), + }; + + mockPermissionCheckService = { + checkPermission: vi.fn(), + }; + + mockAttendeeRepository = { + findById: vi.fn(), + }; + + mockLog = { + error: vi.fn(), + }; + + vi.mocked(BookingRepository).mockImplementation(() => mockBookingRepository as unknown as BookingRepository); + vi.mocked(UserRepository).mockImplementation(() => mockUserRepository as unknown as UserRepository); + vi.mocked(MembershipRepository).mockImplementation(() => mockMembershipRepository as unknown as MembershipRepository); + vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as unknown as PermissionCheckService); + + service = new BookingAuditViewerService({ + bookingAuditRepository: mockBookingAuditRepository as unknown as IBookingAuditRepository, + userRepository: mockUserRepository as unknown as UserRepository, + bookingRepository: mockBookingRepository as unknown as BookingRepository, + membershipRepository: mockMembershipRepository as unknown as MembershipRepository, + attendeeRepository: mockAttendeeRepository as unknown as IAttendeeRepository, + log: mockLog as unknown as ISimpleLogger, + }); + }); + + describe("getAuditLogsForBooking", () => { + describe("when user has permission to view audit logs", () => { + beforeEach(() => { + mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( + createMockTeamBooking({ teamId: 100 }) + ); + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should return enriched audit logs with actor information", async () => { + const mockLog = createMockAuditLog(); + const mockUser = createMockUser(); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(mockUser); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "America/New_York", + organizationId: 200, + }); + + expect(result.bookingUid).toBe("booking-uid-123"); + expect(result.auditLogs).toHaveLength(1); + expect(result.auditLogs[0]).toMatchObject({ + id: "audit-log-1", + bookingUid: "booking-uid-123", + action: "CREATED", + type: "RECORD_CREATED", + actor: expect.objectContaining({ + type: "USER", + displayName: "John Doe", + displayEmail: "john@example.com", + displayAvatar: null, + }), + }); + }); + + it("should format timestamps as ISO strings", async () => { + const mockLog = createMockAuditLog({ + timestamp: new Date("2024-01-15T10:00:00Z"), + createdAt: new Date("2024-01-15T10:00:00Z"), + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "America/New_York", + organizationId: 200, + }); + + expect(result.auditLogs[0].timestamp).toBe("2024-01-15T10:00:00.000Z"); + expect(result.auditLogs[0].createdAt).toBe("2024-01-15T10:00:00.000Z"); + }); + + it("should include action display title with translation key", async () => { + const mockLog = createMockAuditLog({ action: "CREATED" }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "America/New_York", + organizationId: 200, + }); + + expect(result.auditLogs[0].actionDisplayTitle).toEqual({ + key: "booking_audit_action.created", + }); + }); + + it("should handle multiple audit logs in correct order", async () => { + const logs = [ + createMockAuditLog({ + id: "log-1", + action: "CREATED", + timestamp: new Date("2024-01-15T10:00:00Z"), + data: { version: 1, fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" } } + }), + createMockAuditLog({ + id: "log-2", + action: "ACCEPTED", + timestamp: new Date("2024-01-15T11:00:00Z"), + data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } } + }), + createMockAuditLog({ + id: "log-3", + action: "CANCELLED", + timestamp: new Date("2024-01-15T12:00:00Z"), + data: { + version: 1, + fields: { + status: { old: "ACCEPTED", new: "CANCELLED" }, + cancellationReason: { old: null, new: "User requested" }, + cancelledBy: { old: null, new: "user-123" }, + } + } + }), + ]; + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue(logs); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "America/New_York", + organizationId: 200, + }); + + expect(result.auditLogs).toHaveLength(3); + expect(result.auditLogs[0].id).toBe("log-1"); + expect(result.auditLogs[1].id).toBe("log-2"); + expect(result.auditLogs[2].id).toBe("log-3"); + }); + + it("should return empty array when no audit logs exist", async () => { + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([]); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "America/New_York", + organizationId: 200, + }); + + expect(result.bookingUid).toBe("booking-uid-123"); + expect(result.auditLogs).toEqual([]); + }); + }); + + describe("when enriching actor information", () => { + beforeEach(() => { + mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( + createMockTeamBooking({ teamId: 100 }) + ); + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should enrich USER actor with user details from repository", async () => { + const mockLog = createMockAuditLog({ + actorType: "USER", + actorUserUuid: "user-uuid-456", + }); + const mockUser = createMockUser({ + name: "Jane Smith", + email: "jane@example.com", + avatarUrl: "https://example.com/avatar.jpg", + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(mockUser); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "user-uuid-456" }); + expect(result.auditLogs[0].actor).toMatchObject({ + displayName: "Jane Smith", + displayEmail: "jane@example.com", + displayAvatar: "https://example.com/avatar.jpg", + }); + }); + + it("should use email as display name when user name is null", async () => { + const mockLog = createMockAuditLog({ + actorType: "USER", + actorName: null + }); + const mockUser = createMockUser({ name: null, email: "user@example.com" }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(mockUser); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs[0].actor.displayName).toBe("user@example.com"); + }); + + it("should show 'Deleted User' when user not found in repository", async () => { + const mockLog = createMockAuditLog({ actorType: "USER" }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(null); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs[0].actor).toMatchObject({ + displayName: "Deleted User", + displayEmail: null, + displayAvatar: null, + }); + }); + + it("should show 'Cal.com' for SYSTEM actor", async () => { + const mockLog = createMockAuditLog({ + actorType: "SYSTEM", + actorUserUuid: null, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs[0].actor).toMatchObject({ + type: "SYSTEM", + displayName: "Cal.com", + displayEmail: null, + displayAvatar: null, + }); + expect(mockUserRepository.findByUuid).not.toHaveBeenCalled(); + }); + + it("should show 'Guest' for GUEST actor without name", async () => { + const mockLog = createMockAuditLog({ + actorType: "GUEST", + actorUserUuid: null, + actorName: null, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs[0].actor).toMatchObject({ + type: "GUEST", + displayName: "Guest", + displayEmail: null, + displayAvatar: null, + }); + }); + + it("should use provided name for GUEST actor", async () => { + const mockLog = createMockAuditLog({ + actorType: "GUEST", + actorUserUuid: null, + actorName: "External Guest", + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs[0].actor.displayName).toBe("External Guest"); + }); + + it("should show 'Attendee' for ATTENDEE actor without name", async () => { + const mockLog = createMockAuditLog({ + actorType: "ATTENDEE", + actorUserUuid: null, + actorName: null, + }); + + // Update the mock to include attendeeId in the actor + const logWithAttendeeId = { + ...mockLog, + actor: { + ...mockLog.actor, + attendeeId: 999, + }, + }; + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]); + mockAttendeeRepository.findById.mockResolvedValue({ + id: 999, + name: null, + email: "attendee@example.com", + }); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(999); + expect(result.auditLogs[0].actor).toMatchObject({ + type: "ATTENDEE", + displayName: "attendee@example.com", + displayEmail: "attendee@example.com", + displayAvatar: null, + }); + }); + + it("should use provided name for ATTENDEE actor", async () => { + const mockLog = createMockAuditLog({ + actorType: "ATTENDEE", + actorUserUuid: null, + actorName: "Meeting Participant", + }); + + // Update the mock to include attendeeId in the actor + const logWithAttendeeId = { + ...mockLog, + actor: { + ...mockLog.actor, + attendeeId: 888, + }, + }; + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]); + mockAttendeeRepository.findById.mockResolvedValue({ + id: 888, + name: "Meeting Participant", + email: "participant@example.com", + }); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(888); + expect(result.auditLogs[0].actor.displayName).toBe("Meeting Participant"); + expect(result.auditLogs[0].actor.displayEmail).toBe("participant@example.com"); + }); + + it("should show 'Deleted Attendee' when attendee not found in repository", async () => { + const mockLog = createMockAuditLog({ + actorType: "ATTENDEE", + actorUserUuid: null, + actorName: null, + }); + + // Update the mock to include attendeeId in the actor + const logWithAttendeeId = { + ...mockLog, + actor: { + ...mockLog.actor, + attendeeId: 777, + }, + }; + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]); + mockAttendeeRepository.findById.mockResolvedValue(null); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(777); + expect(result.auditLogs[0].actor).toMatchObject({ + type: "ATTENDEE", + displayName: "Deleted Attendee", + displayEmail: null, + displayAvatar: null, + }); + }); + }); + + describe("when handling rescheduled bookings", () => { + beforeEach(() => { + mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( + createMockTeamBooking({ teamId: 100 }) + ); + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should include rescheduled from log when booking was created from reschedule", async () => { + const currentBookingLog = createMockAuditLog({ + id: "current-log", + bookingUid: "new-booking-uid", + action: "CREATED", + }); + + const rescheduledLog = createMockAuditLog({ + id: "rescheduled-log", + bookingUid: "old-booking-uid", + action: "RESCHEDULED", + data: { + version: 1, + fields: { + startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" }, + endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" }, + rescheduledToUid: { old: null, new: "new-booking-uid" }, + }, + }, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]); + mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid"); + mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "new-booking-uid", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs).toHaveLength(2); + expect(result.auditLogs[0].id).toBe("rescheduled-log"); + expect(result.auditLogs[0].bookingUid).toBe("new-booking-uid"); + expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from"); + expect(result.auditLogs[0].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid"); + expect(result.auditLogs[1].id).toBe("current-log"); + }); + + it("should not include rescheduled from log when booking was not rescheduled", async () => { + const mockLog = createMockAuditLog(); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockBookingRepository.getFromRescheduleUid.mockResolvedValue(null); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs).toHaveLength(1); + expect(mockBookingAuditRepository.findRescheduledLogsOfBooking).not.toHaveBeenCalled(); + }); + + it("should continue without rescheduled from log when matching log not found", async () => { + const currentBookingLog = createMockAuditLog({ + bookingUid: "new-booking-uid", + }); + + const rescheduledLog = createMockAuditLog({ + id: "rescheduled-log", + bookingUid: "old-booking-uid", + action: "RESCHEDULED", + data: { + version: 1, + fields: { + startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" }, + endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" }, + rescheduledToUid: { old: null, new: "different-booking-uid" }, + }, + }, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]); + mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid"); + mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "new-booking-uid", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs).toHaveLength(1); + expect(result.auditLogs[0].id).toBe("audit-log-1"); + expect(mockLog.error).toHaveBeenCalledWith( + "No rescheduled log found for booking old-booking-uid -> new-booking-uid" + ); + }); + }); + + describe("when permission check fails", () => { + it("should throw error when user lacks permission", async () => { + mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( + createMockTeamBooking({ teamId: 100 }) + ); + mockPermissionCheckService.checkPermission.mockResolvedValue(false); + mockMembershipRepository.hasMembership.mockResolvedValue(true); + + await expect( + service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }) + ).rejects.toThrow(BookingAuditPermissionError); + + expect(mockBookingAuditRepository.findAllForBooking).not.toHaveBeenCalled(); + }); + + it("should throw error when organization ID is null", async () => { + await expect( + service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: null, + }) + ).rejects.toThrow(BookingAuditPermissionError); + + expect(mockBookingAuditRepository.findAllForBooking).not.toHaveBeenCalled(); + }); + + it("should throw error when booking not found", async () => { + mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(null); + + await expect( + service.getAuditLogsForBooking({ + bookingUid: "non-existent-booking", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }) + ).rejects.toThrow(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED); + + expect(mockBookingAuditRepository.findAllForBooking).not.toHaveBeenCalled(); + }); + }); + + describe("when handling different timezones", () => { + beforeEach(() => { + mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( + createMockTeamBooking({ teamId: 100 }) + ); + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should pass user timezone to action service for display formatting", async () => { + const mockLog = createMockAuditLog({ + action: "RESCHEDULED", + data: { + version: 1, + fields: { + startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" }, + endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" }, + rescheduledToUid: { old: null, new: "new-booking-uid" }, + }, + }, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "America/Los_Angeles", + organizationId: 200, + }); + + expect(result.auditLogs[0].actionDisplayTitle).toHaveProperty("params"); + expect(result.auditLogs[0].displayJson).toBeDefined(); + }); + }); + + describe("when handling seat audit actions", () => { + beforeEach(() => { + mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( + createMockTeamBooking({ teamId: 100 }) + ); + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should handle SEAT_BOOKED action with seat information", async () => { + const mockLog = createMockAuditLog({ + id: "seat-booked-log", + action: "SEAT_BOOKED", + data: { + version: 1, + fields: { + seatReferenceUid: "seat-ref-123", + attendeeEmail: "attendee@example.com", + attendeeName: "Jane Attendee", + startTime: 1705315200000, + endTime: 1705318800000, + }, + }, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "America/New_York", + organizationId: 200, + }); + + expect(result.auditLogs).toHaveLength(1); + expect(result.auditLogs[0].action).toBe("SEAT_BOOKED"); + expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.seat_booked"); + expect(result.auditLogs[0].displayJson).toBeDefined(); + }); + + it("should handle SEAT_RESCHEDULED action with time changes", async () => { + const mockLog = createMockAuditLog({ + id: "seat-rescheduled-log", + action: "SEAT_RESCHEDULED", + data: { + version: 1, + fields: { + seatReferenceUid: "seat-ref-123", + attendeeEmail: "attendee@example.com", + startTime: { + old: "2024-01-15T10:00:00Z", + new: "2024-01-16T10:00:00Z", + }, + endTime: { + old: "2024-01-15T11:00:00Z", + new: "2024-01-16T11:00:00Z", + }, + rescheduledToBookingUid: { + old: null, + new: "new-booking-uid", + }, + }, + }, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "America/New_York", + organizationId: 200, + }); + + expect(result.auditLogs).toHaveLength(1); + expect(result.auditLogs[0].action).toBe("SEAT_RESCHEDULED"); + expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.seat_rescheduled"); + expect(result.auditLogs[0].actionDisplayTitle.params).toBeDefined(); + expect(result.auditLogs[0].actionDisplayTitle.components).toBeDefined(); + expect(result.auditLogs[0].displayJson).toBeDefined(); + }); + + it("should handle SEAT_RESCHEDULED action without moving to different booking", async () => { + const mockLog = createMockAuditLog({ + id: "seat-rescheduled-same-booking", + action: "SEAT_RESCHEDULED", + data: { + version: 1, + fields: { + seatReferenceUid: "seat-ref-123", + attendeeEmail: "attendee@example.com", + startTime: { + old: "2024-01-15T10:00:00Z", + new: "2024-01-15T11:00:00Z", + }, + endTime: { + old: "2024-01-15T11:00:00Z", + new: "2024-01-15T12:00:00Z", + }, + rescheduledToBookingUid: { + old: null, + new: null, + }, + }, + }, + }); + + mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); + mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + + const result = await service.getAuditLogsForBooking({ + bookingUid: "booking-uid-123", + userId: 123, + userEmail: "user@example.com", + userTimeZone: "UTC", + organizationId: 200, + }); + + expect(result.auditLogs).toHaveLength(1); + expect(result.auditLogs[0].action).toBe("SEAT_RESCHEDULED"); + expect(result.auditLogs[0].actionDisplayTitle.components).toBeUndefined(); + }); + }); + }); +}); 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 8160f3c1a3..c02f532165 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,8 +1,7 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { formatInTimeZone } from "date-fns-tz"; import { prisma } from "@calcom/prisma"; -import { BookingStatus } from "@calcom/prisma/enums"; +import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; import type { BookingAuditTaskConsumer } from "./BookingAuditTaskConsumer"; import type { BookingAuditViewerService } from "./BookingAuditViewerService"; @@ -10,278 +9,480 @@ import { makeUserActor } from "../../../bookings/lib/types/actor"; import { getBookingAuditTaskConsumer } from "../../di/BookingAuditTaskConsumer.container"; import { getBookingAuditViewerService } from "../../di/BookingAuditViewerService.container"; -/** - * Integration tests for booking audit system - * Tests the complete write-read cycle using real database - */ +const generateUniqueId = () => { + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(7); + return `${timestamp}-${randomSuffix}`; +}; + +const createTestUser = async (overrides?: { email?: string; username?: string; name?: string }) => { + const uniqueId = generateUniqueId(); + return prisma.user.create({ + data: { + email: overrides?.email || `test-user-${uniqueId}@example.com`, + username: overrides?.username || `testuser-${uniqueId}`, + name: overrides?.name || "Test User", + }, + }); +}; + +const createTestOrganization = async (overrides?: { name?: string; slug?: string }) => { + const uniqueId = generateUniqueId(); + return prisma.team.create({ + data: { + name: overrides?.name || `Test Org ${uniqueId}`, + slug: overrides?.slug || `test-org-${uniqueId}`, + isOrganization: true, + }, + }); +}; + +const createTestMembership = async ( + userId: number, + teamId: number, + overrides?: { role?: MembershipRole; accepted?: boolean } +) => { + return prisma.membership.create({ + data: { + userId, + teamId, + role: overrides?.role || MembershipRole.ADMIN, + accepted: overrides?.accepted ?? true, + }, + }); +}; + +const createTestEventType = async ( + userId: number, + overrides?: { title?: string; slug?: string; length?: number } +) => { + const uniqueId = generateUniqueId(); + return prisma.eventType.create({ + data: { + title: overrides?.title || "Test Event Type", + slug: overrides?.slug || `test-event-${uniqueId}`, + length: overrides?.length || 60, + userId, + }, + }); +}; + +const createTestBooking = async ( + userId: number, + eventTypeId: number, + overrides?: { + uid?: string; + title?: string; + startTime?: Date; + endTime?: Date; + status?: BookingStatus; + attendees?: Array<{ email: string; name: string; timeZone: string }>; + } +) => { + const uniqueId = generateUniqueId(); + const startTime = overrides?.startTime || new Date(); + const endTime = overrides?.endTime || new Date(startTime.getTime() + 60 * 60 * 1000); + + return prisma.booking.create({ + data: { + uid: overrides?.uid || `test-booking-${uniqueId}`, + title: overrides?.title || "Test Booking", + startTime, + endTime, + userId, + eventTypeId, + status: overrides?.status || BookingStatus.ACCEPTED, + attendees: { + create: overrides?.attendees || [], + }, + }, + }); +}; + +const enableFeatureForOrganization = async (organizationId: number, featureSlug: string) => { + await prisma.feature.upsert({ + where: { slug: featureSlug }, + create: { + slug: featureSlug, + enabled: true, + description: `Test feature: ${featureSlug}`, + }, + update: { + enabled: true, + }, + }); + + await prisma.teamFeatures.upsert({ + where: { + teamId_featureId: { + teamId: organizationId, + featureId: featureSlug, + }, + }, + create: { + teamId: organizationId, + featureId: featureSlug, + assignedBy: "test-system", + }, + update: {}, + }); +}; + +const cleanupTestData = async (testData: { + bookingUid?: string; + userUuids?: string[]; + attendeeEmails?: string[]; + eventTypeId?: number; + organizationId?: number; + userIds?: number[]; + featureSlug?: string; +}) => { + if (testData.bookingUid) { + await prisma.bookingAudit.deleteMany({ + where: { bookingUid: testData.bookingUid }, + }); + } + + if (testData.userUuids?.length || testData.attendeeEmails?.length) { + await prisma.auditActor.deleteMany({ + where: { + OR: [ + ...(testData.userUuids?.map((uuid) => ({ userUuid: uuid })) || []), + ...(testData.attendeeEmails?.map((email) => ({ email })) || []), + ], + }, + }); + } + + if (testData.attendeeEmails?.length) { + await prisma.attendee.deleteMany({ + where: { email: { in: testData.attendeeEmails } }, + }); + } + + if (testData.bookingUid) { + await prisma.booking.deleteMany({ + where: { uid: testData.bookingUid }, + }); + } + + if (testData.eventTypeId) { + await prisma.eventType.deleteMany({ + where: { id: testData.eventTypeId }, + }); + } + + if (testData.organizationId) { + if (testData.featureSlug) { + await prisma.teamFeatures.deleteMany({ + where: { + teamId: testData.organizationId, + featureId: testData.featureSlug, + }, + }); + } + await prisma.membership.deleteMany({ + where: { teamId: testData.organizationId }, + }); + await prisma.team.deleteMany({ + where: { id: testData.organizationId }, + }); + } + + if (testData.userIds?.length) { + await prisma.user.deleteMany({ + where: { id: { in: testData.userIds } }, + }); + } +}; + describe("Booking Audit Integration", () => { let bookingAuditTaskConsumer: BookingAuditTaskConsumer; let bookingAuditViewerService: BookingAuditViewerService; - // Test data holders - let testUserId: number; - let testUserUuid: string; - let testUserEmail: string; - let testBookingUid: string; - let testEventTypeId: number; - let testAttendeeEmail: string; - let testAttendeeUserId: number; + let testData: { + owner: { id: number; uuid: string; email: string }; + attendee: { id: number; email: string }; + organization: { id: number }; + eventType: { id: number }; + booking: { uid: string; startTime: Date; endTime: Date; status: BookingStatus }; + }; beforeEach(async () => { - // Initialize services from DI containers bookingAuditTaskConsumer = getBookingAuditTaskConsumer(); bookingAuditViewerService = getBookingAuditViewerService(); - // Create test user - const timestamp = Date.now(); - const randomSuffix = Math.random().toString(36).substring(7); - const testUser = await prisma.user.create({ - data: { - email: `test-audit-user-${timestamp}-${randomSuffix}@example.com`, - username: `testaudituser-${timestamp}-${randomSuffix}`, - name: "Test Audit User", - }, - }); - testUserId = testUser.id; - testUserUuid = testUser.uuid; - testUserEmail = testUser.email; + const owner = await createTestUser({ name: "Test Audit User" }); + const organization = await createTestOrganization(); + await createTestMembership(owner.id, organization.id); + await enableFeatureForOrganization(organization.id, "booking-audit"); + const eventType = await createTestEventType(owner.id); + const attendee = await createTestUser({ name: "Test Attendee" }); - // Create test event type - const eventType = await prisma.eventType.create({ - data: { - title: "Test Event Type", - slug: `test-event-${timestamp}-${randomSuffix}`, - length: 60, - userId: testUserId, - }, - }); - testEventTypeId = eventType.id; - - // Create test attendee user for permission tests (needed before booking creation) - const attendeeUser = await prisma.user.create({ - data: { - email: `test-attendee-${timestamp}-${randomSuffix}@example.com`, - username: `testattendee-${timestamp}-${randomSuffix}`, - name: "Test Attendee", - }, - }); - testAttendeeUserId = attendeeUser.id; - testAttendeeEmail = attendeeUser.email; - - // Create test booking with attendee in single atomic operation - const startTime = new Date(); - const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); - testBookingUid = `test-booking-${timestamp}-${randomSuffix}`; - - await prisma.booking.create({ - data: { - uid: testBookingUid, - title: "Test Booking", - startTime, - endTime, - userId: testUserId, - eventTypeId: testEventTypeId, - status: BookingStatus.ACCEPTED, - attendees: { - create: [ - { - email: testAttendeeEmail, - name: "Test Attendee", - timeZone: "UTC", - }, - ], + const booking = await createTestBooking(owner.id, eventType.id, { + attendees: [ + { + email: attendee.email, + name: attendee.name || "Test Attendee", + timeZone: "UTC", }, - }, + ], }); + + testData = { + owner: { id: owner.id, uuid: owner.uuid, email: owner.email }, + attendee: { id: attendee.id, email: attendee.email }, + organization: { id: organization.id }, + eventType: { id: eventType.id }, + booking: { + uid: booking.uid, + startTime: booking.startTime, + endTime: booking.endTime, + status: booking.status, + }, + }; }); afterEach(async () => { - // Clean up test data in correct order to respect foreign key constraints + if (!testData) return; - // Delete audit logs first (references actor) - await prisma.bookingAudit.deleteMany({ - where: { bookingUid: testBookingUid }, - }); - - // Delete actors - await prisma.auditActor.deleteMany({ - where: { - OR: [ - { userUuid: testUserUuid }, - { email: testAttendeeEmail }, - ], - }, - }); - - // Delete attendees - await prisma.attendee.deleteMany({ - where: { email: testAttendeeEmail }, - }); - - // Delete booking - await prisma.booking.deleteMany({ - where: { uid: testBookingUid }, - }); - - // Delete event type - await prisma.eventType.deleteMany({ - where: { id: testEventTypeId }, - }); - - // Delete users - await prisma.user.deleteMany({ - where: { - id: { - in: [testUserId, testAttendeeUserId], - }, - }, + await cleanupTestData({ + bookingUid: testData.booking?.uid, + userUuids: testData.owner?.uuid ? [testData.owner.uuid] : [], + attendeeEmails: testData.attendee?.email ? [testData.attendee.email] : [], + eventTypeId: testData.eventType?.id, + organizationId: testData.organization?.id, + userIds: [testData.owner?.id, testData.attendee?.id].filter((id): id is number => id !== undefined), + featureSlug: "booking-audit", }); }); - describe("CREATED action end-to-end flow", () => { - it("should create audit record and retrieve it with correct formatting", async () => { - // Arrange: Get booking details - const booking = await prisma.booking.findUnique({ - where: { uid: testBookingUid }, - select: { - startTime: true, - endTime: true, - status: true, - }, - }); + describe("when single booking is created", () => { + it("should create audit record and retrieve it with correct data formatting", async () => { + const actor = makeUserActor(testData.owner.uuid); - expect(booking).toBeDefined(); - - // Create actor using helper - const actor = makeUserActor(testUserUuid); - - // Act: Create audit record using TaskConsumer await bookingAuditTaskConsumer.onBookingAction({ - bookingUid: testBookingUid, + bookingUid: testData.booking.uid, actor, action: "CREATED", + source: "WEBAPP", + operationId: `op-${Date.now()}`, data: { - startTime: booking!.startTime.getTime(), - endTime: booking!.endTime.getTime(), - status: booking!.status, + startTime: testData.booking.startTime.getTime(), + endTime: testData.booking.endTime.getTime(), + status: testData.booking.status, }, timestamp: Date.now(), }); - // Retrieve audit logs - const result = await bookingAuditViewerService.getAuditLogsForBooking( - testBookingUid, - testUserId, - testUserEmail - ); + const result = await bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid: testData.booking.uid, + userId: testData.owner.id, + userEmail: testData.owner.email, + userTimeZone: "UTC", + organizationId: testData.organization.id, + }); - // Assert: Verify audit log exists and has correct data - expect(result.bookingUid).toBe(testBookingUid); + expect(result.bookingUid).toBe(testData.booking.uid); expect(result.auditLogs).toHaveLength(1); const auditLog = result.auditLogs[0]; - expect(auditLog.bookingUid).toBe(testBookingUid); + expect(auditLog.bookingUid).toBe(testData.booking.uid); expect(auditLog.action).toBe("CREATED"); expect(auditLog.type).toBe("RECORD_CREATED"); - // Verify audit data matches (getDisplayJson returns fields only, not versioned wrapper) - // getDisplayJson formats dates using formatDateTimeInTimeZone (yyyy-MM-dd HH:mm:ss format) - const displayData = auditLog.data - const expectedStartTime = formatInTimeZone(booking!.startTime, "UTC", "yyyy-MM-dd HH:mm:ss"); - const expectedEndTime = formatInTimeZone(booking!.endTime, "UTC", "yyyy-MM-dd HH:mm:ss"); - expect(displayData?.startTime).toBe(expectedStartTime); - expect(displayData?.endTime).toBe(expectedEndTime); - expect(displayData?.status).toBe(booking!.status); + const displayData = auditLog.displayJson as Record; + expect(displayData).toBeDefined(); + expect(displayData.startTime).toBeDefined(); + expect(typeof displayData.startTime).toBe("string"); + expect(displayData.startTime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + + expect(displayData.endTime).toBeDefined(); + expect(typeof displayData.endTime).toBe("string"); + expect(displayData.endTime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + + expect(displayData.status).toBe(testData.booking.status); }); - it("should enrich actor information with user details", async () => { - // Arrange: Get booking details - const booking = await prisma.booking.findUnique({ - where: { uid: testBookingUid }, - select: { startTime: true, endTime: true, status: true }, - }); + it("should enrich actor information with user details from database", async () => { + const actor = makeUserActor(testData.owner.uuid); - // Create actor using helper - const actor = makeUserActor(testUserUuid); - - // Create audit record using TaskConsumer await bookingAuditTaskConsumer.onBookingAction({ - bookingUid: testBookingUid, + bookingUid: testData.booking.uid, actor, action: "CREATED", + source: "WEBAPP", + operationId: `op-${Date.now()}`, data: { - startTime: booking!.startTime.getTime(), - endTime: booking!.endTime.getTime(), - status: booking!.status, + startTime: testData.booking.startTime.getTime(), + endTime: testData.booking.endTime.getTime(), + status: testData.booking.status, }, timestamp: Date.now(), }); - // Act: Retrieve audit logs - const result = await bookingAuditViewerService.getAuditLogsForBooking( - testBookingUid, - testUserId, - testUserEmail - ); + const result = await bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid: testData.booking.uid, + userId: testData.owner.id, + userEmail: testData.owner.email, + userTimeZone: "UTC", + organizationId: testData.organization.id, + }); - // Assert: Verify actor enrichment const auditLog = result.auditLogs[0]; expect(auditLog.actor.displayName).toBe("Test Audit User"); - expect(auditLog.actor.displayEmail).toBe(testUserEmail); - expect(auditLog.actor.userUuid).toBe(testUserUuid); + expect(auditLog.actor.displayEmail).toBe(testData.owner.email); + expect(auditLog.actor.userUuid).toBe(testData.owner.uuid); }); - it.skip("should enforce permission checks correctly", async () => { - // Arrange: Get booking details - const booking = await prisma.booking.findUnique({ - where: { uid: testBookingUid }, - select: { startTime: true, endTime: true, status: true }, - }); + it.skip("should deny access to unauthorized users viewing audit logs", async () => { + const actor = makeUserActor(testData.owner.uuid); - // Create actor using helper - const actor = makeUserActor(testUserUuid); - - // Create audit record using TaskConsumer await bookingAuditTaskConsumer.onBookingAction({ - bookingUid: testBookingUid, + bookingUid: testData.booking.uid, actor, action: "CREATED", + source: "WEBAPP", + operationId: `op-${Date.now()}`, data: { - startTime: booking!.startTime.getTime(), - endTime: booking!.endTime.getTime(), - status: booking!.status, + startTime: testData.booking.startTime.getTime(), + endTime: testData.booking.endTime.getTime(), + status: testData.booking.status, }, timestamp: Date.now(), }); - // Act & Assert: Booking owner can view - const ownerResult = await bookingAuditViewerService.getAuditLogsForBooking( - testBookingUid, - testUserId, - testUserEmail - ); + const ownerResult = await bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid: testData.booking.uid, + userId: testData.owner.id, + userEmail: testData.owner.email, + userTimeZone: "UTC", + organizationId: testData.organization.id, + }); expect(ownerResult.auditLogs).toHaveLength(1); - // Act & Assert: Attendee can view - const attendeeResult = await bookingAuditViewerService.getAuditLogsForBooking( - testBookingUid, - testAttendeeUserId, - testAttendeeEmail - ); - expect(attendeeResult.auditLogs).toHaveLength(1); - - // Act & Assert: Unauthorized user cannot view const unauthorizedUserId = 999999; const unauthorizedEmail = "unauthorized@example.com"; await expect( - bookingAuditViewerService.getAuditLogsForBooking( - testBookingUid, - unauthorizedUserId, - unauthorizedEmail - ) - ).rejects.toThrow("You do not have permission to view audit logs for this booking"); + bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid: testData.booking.uid, + userId: unauthorizedUserId, + userEmail: unauthorizedEmail, + userTimeZone: "UTC", + organizationId: testData.organization.id, + }) + ).rejects.toThrow(); + }); + }); + + describe("when multiple bookings are created in bulk", () => { + it("should create audit records for all bookings with same operation ID", async () => { + const booking2 = await createTestBooking(testData.owner.id, testData.eventType.id, { + attendees: [ + { + email: testData.attendee.email, + name: "Test Attendee", + timeZone: "UTC", + }, + ], + }); + + const booking3 = await createTestBooking(testData.owner.id, testData.eventType.id, { + attendees: [ + { + email: testData.attendee.email, + name: "Test Attendee", + timeZone: "UTC", + }, + ], + }); + + const actor = makeUserActor(testData.owner.uuid); + const operationId = `bulk-op-${Date.now()}`; + const timestamp = Date.now(); + + await bookingAuditTaskConsumer.processBulkAuditTask( + { + bookings: [ + { + bookingUid: testData.booking.uid, + data: { + startTime: testData.booking.startTime.getTime(), + endTime: testData.booking.endTime.getTime(), + status: testData.booking.status, + }, + }, + { + bookingUid: booking2.uid, + data: { + startTime: booking2.startTime.getTime(), + endTime: booking2.endTime.getTime(), + status: booking2.status, + }, + }, + { + bookingUid: booking3.uid, + data: { + startTime: booking3.startTime.getTime(), + endTime: booking3.endTime.getTime(), + status: booking3.status, + }, + }, + ], + actor, + action: "CREATED", + source: "WEBAPP", + operationId, + timestamp, + organizationId: testData.organization.id, + }, + "test-task-id" + ); + + const result1 = await bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid: testData.booking.uid, + userId: testData.owner.id, + userEmail: testData.owner.email, + userTimeZone: "UTC", + organizationId: testData.organization.id, + }); + + const result2 = await bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid: booking2.uid, + userId: testData.owner.id, + userEmail: testData.owner.email, + userTimeZone: "UTC", + organizationId: testData.organization.id, + }); + + const result3 = await bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid: booking3.uid, + userId: testData.owner.id, + userEmail: testData.owner.email, + userTimeZone: "UTC", + organizationId: testData.organization.id, + }); + + expect(result1.auditLogs).toHaveLength(1); + expect(result2.auditLogs).toHaveLength(1); + expect(result3.auditLogs).toHaveLength(1); + + expect(result1.auditLogs[0].action).toBe("CREATED"); + expect(result2.auditLogs[0].action).toBe("CREATED"); + expect(result3.auditLogs[0].action).toBe("CREATED"); + + // Verify all bookings share the same operationId + expect(result1.auditLogs[0].operationId).toBe(operationId); + expect(result2.auditLogs[0].operationId).toBe(operationId); + expect(result3.auditLogs[0].operationId).toBe(operationId); + + await cleanupTestData({ + bookingUid: booking2.uid, + }); + await cleanupTestData({ + bookingUid: booking3.uid, + }); }); }); }); - diff --git a/packages/features/booking-audit/lib/types/actionSource.ts b/packages/features/booking-audit/lib/types/actionSource.ts new file mode 100644 index 0000000000..627a2e4c69 --- /dev/null +++ b/packages/features/booking-audit/lib/types/actionSource.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +// Action sources for audit tracking +export const ActionSourceSchema = z.enum(["API_V1", "API_V2", "WEBAPP", "WEBHOOK", "UNKNOWN"]); +export type ActionSource = z.infer; diff --git a/packages/features/booking-audit/lib/types/bookingAuditTask.ts b/packages/features/booking-audit/lib/types/bookingAuditTask.ts index 699a672a7c..d98849e452 100644 --- a/packages/features/booking-audit/lib/types/bookingAuditTask.ts +++ b/packages/features/booking-audit/lib/types/bookingAuditTask.ts @@ -1,24 +1,13 @@ import { z } from "zod"; -import { ActorSchema } from "@calcom/features/bookings/lib/types/actor"; -import { CreatedAuditActionService } from "../actions/CreatedAuditActionService"; -import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; -import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService"; -import { CancelledAuditActionService } from "../actions/CancelledAuditActionService"; -import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService"; -import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService"; -import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService"; -import { RejectedAuditActionService } from "../actions/RejectedAuditActionService"; -import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService"; -import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService"; -import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService"; -import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService"; +import { PiiFreeActorSchema } from "../../../bookings/lib/types/actor"; +import { ActionSourceSchema } from "./actionSource"; /** * Supported booking audit actions * Used for runtime validation of action field */ -export const BookingAuditActionSchema = z.enum([ +const BookingAuditActionSchema = z.enum([ "CREATED", "RESCHEDULED", "ACCEPTED", @@ -31,47 +20,55 @@ export const BookingAuditActionSchema = z.enum([ "REASSIGNMENT", "LOCATION_CHANGED", "ATTENDEE_NO_SHOW_UPDATED", + "SEAT_BOOKED", + "SEAT_RESCHEDULED", ]); export type BookingAuditAction = z.infer; -type AuditActionData = { - action: T["TYPE"]; - data: z.infer; -}; +const actionAgnosticDataSchema = z.unknown(); -/** - * Producer action data type - discriminated union for type-safe queueing - * Used by the legacy queueAudit method for backwards compatibility - */ -export type BookingAuditTaskProducerActionData = - | AuditActionData - | 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({ +const bookingAuditPayloadSchema = z.object({ bookingUid: z.string(), - actor: ActorSchema, + data: actionAgnosticDataSchema, +}); + +export const SingleBookingAuditTaskConsumerSchema = z.object({ + isBulk: z.literal(false), + ...bookingAuditPayloadSchema.shape, + actor: PiiFreeActorSchema, organizationId: z.number().nullable(), timestamp: z.number(), action: BookingAuditActionSchema, - data: z.unknown(), + source: ActionSourceSchema.default("UNKNOWN"), + operationId: z.string(), }); -export type BookingAuditTaskBasePayload = z.infer; +export type SingleBookingAuditTaskConsumerPayload = z.infer; + +/** + * Bulk booking audit task payload schema + * + * Used for operations that affect multiple bookings in a single action. + * Contains an array of bookings, each with bookingUid and action-specific data. + * All bookings share the same actor, organizationId, timestamp, action, source, and operationId. + */ +export const BulkBookingAuditTaskConsumerSchema = z.object({ + isBulk: z.literal(true), + bookings: z.array(bookingAuditPayloadSchema).min(1), + actor: PiiFreeActorSchema, + organizationId: z.number().nullable(), + timestamp: z.number(), + action: BookingAuditActionSchema, + source: ActionSourceSchema.default("UNKNOWN"), + operationId: z.string(), +}); + +export type BulkBookingAuditTaskConsumerPayload = z.infer; + +export const BookingAuditTaskConsumerSchema = z.discriminatedUnion("isBulk", [ + SingleBookingAuditTaskConsumerSchema, + BulkBookingAuditTaskConsumerSchema, +]); + +export type BookingAuditTaskConsumerPayload = z.infer; diff --git a/packages/features/bookings/di/Attendee.module.ts b/packages/features/bookings/di/Attendee.module.ts new file mode 100644 index 0000000000..fda464dff9 --- /dev/null +++ b/packages/features/bookings/di/Attendee.module.ts @@ -0,0 +1,15 @@ +import { AttendeeRepository } from "@calcom/features/bookings/repositories/AttendeeRepository"; +import { type Container, createModule } from "@calcom/features/di/di"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; + +export const attendeeRepositoryModule = createModule(); +const token = DI_TOKENS.ATTENDEE_REPOSITORY; +const moduleToken = DI_TOKENS.ATTENDEE_REPOSITORY_MODULE; +attendeeRepositoryModule.bind(token).toClass(AttendeeRepository, [DI_TOKENS.PRISMA_CLIENT]); + +export const moduleLoader = { + token, + loadModule: function (container: Container) { + container.load(moduleToken, attendeeRepositoryModule); + }, +}; diff --git a/packages/features/bookings/di/tokens.ts b/packages/features/bookings/di/tokens.ts index 691bad3feb..a91e6497b4 100644 --- a/packages/features/bookings/di/tokens.ts +++ b/packages/features/bookings/di/tokens.ts @@ -11,4 +11,6 @@ export const BOOKING_DI_TOKENS = { BOOKING_EMAIL_SMS_HANDLER_MODULE: Symbol("BookingEmailSmsHandlerModule"), BOOKING_EVENT_HANDLER_SERVICE: Symbol("BookingEventHandlerService"), BOOKING_EVENT_HANDLER_SERVICE_MODULE: Symbol("BookingEventHandlerServiceModule"), + ATTENDEE_REPOSITORY: Symbol("AttendeeRepository"), + ATTENDEE_REPOSITORY_MODULE: Symbol("AttendeeRepositoryModule"), }; diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts index ea84e90166..9fb2666e14 100644 --- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -1,10 +1,23 @@ import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface"; +import type { AcceptedAuditData } from "@calcom/features/booking-audit/lib/actions/AcceptedAuditActionService"; +import type { CancelledAuditData } from "@calcom/features/booking-audit/lib/actions/CancelledAuditActionService"; +import type { RejectedAuditData } from "@calcom/features/booking-audit/lib/actions/RejectedAuditActionService"; +import type { RescheduleRequestedAuditData } from "@calcom/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService"; +import type { AttendeeAddedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeAddedAuditActionService"; +import type { AttendeeRemovedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService"; +import type { ReassignmentAuditData } from "@calcom/features/booking-audit/lib/actions/ReassignmentAuditActionService"; +import type { LocationChangedAuditData } from "@calcom/features/booking-audit/lib/actions/LocationChangedAuditActionService"; +import type { HostNoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService"; +import type { AttendeeNoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService"; +import type { SeatBookedAuditData } from "@calcom/features/booking-audit/lib/actions/SeatBookedAuditActionService"; +import type { SeatRescheduledAuditData } from "@calcom/features/booking-audit/lib/actions/SeatRescheduledAuditActionService"; +import type { CreatedAuditData } from "@calcom/features/booking-audit/lib/actions/CreatedAuditActionService"; +import type { RescheduledAuditData } from "@calcom/features/booking-audit/lib/actions/RescheduledAuditActionService"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { Actor } from "../types/actor"; -import { IS_PRODUCTION } from "@calcom/lib/constants"; - import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types"; interface BookingEventHandlerDeps { @@ -13,43 +26,87 @@ interface BookingEventHandlerDeps { bookingAuditProducerService: BookingAuditProducerService; } +interface OnBookingCreatedParams { + payload: BookingCreatedPayload; + actor: Actor; + auditData: CreatedAuditData; + source: ActionSource; + operationId?: string | null; +} + +interface OnBookingRescheduledParams { + payload: BookingRescheduledPayload; + actor: Actor; + auditData: RescheduledAuditData; + source: ActionSource; + operationId?: string | null; +} + +interface BaseBookingEventParams { + bookingUid: string; + actor: Actor; + organizationId: number | null; + auditData: TAuditData; + source: ActionSource; + operationId?: string | null; +} + +type OnBookingAcceptedParams = BaseBookingEventParams; +type OnBookingCancelledParams = BaseBookingEventParams; +type OnRescheduleRequestedParams = BaseBookingEventParams; +type OnAttendeeAddedParams = BaseBookingEventParams; +type OnHostNoShowUpdatedParams = BaseBookingEventParams; +type OnBookingRejectedParams = BaseBookingEventParams; +type OnAttendeeRemovedParams = BaseBookingEventParams; +type OnReassignmentParams = BaseBookingEventParams; +type OnLocationChangedParams = BaseBookingEventParams; +type OnAttendeeNoShowUpdatedParams = BaseBookingEventParams; +type OnSeatBookedParams = BaseBookingEventParams; +type OnSeatRescheduledParams = BaseBookingEventParams; + export class BookingEventHandlerService { private readonly log: BookingEventHandlerDeps["log"]; + private readonly bookingAuditProducerService: BookingEventHandlerDeps["bookingAuditProducerService"]; + constructor(private readonly deps: BookingEventHandlerDeps) { this.log = deps.log; + this.bookingAuditProducerService = deps.bookingAuditProducerService; } - async onBookingCreated(payload: BookingCreatedPayload, actor: Actor) { + async onBookingCreated(params: OnBookingCreatedParams) { + const { payload, actor, auditData, source, operationId } = params; this.log.debug("onBookingCreated", safeStringify(payload)); if (payload.config.isDryRun) { return; } await this.onBookingCreatedOrRescheduled(payload); - if (IS_PRODUCTION) { - // Skip queueing audit for production environments till we are absolutely sure that the payload schema is correct for CREATED action - // We might get more clarity as we implement more actions and test them - return; - } - try { - await this.deps.bookingAuditProducerService.queueAudit(payload.booking.uid, actor, payload.organizationId, { - action: "CREATED", - data: { - startTime: payload.booking.startTime.getTime(), - endTime: payload.booking.endTime.getTime(), - status: payload.booking.status, - }, - }); - } catch (error) { - this.log.error("Error while queueing booking audit", safeStringify(error)); - } + await this.deps.bookingAuditProducerService.queueCreatedAudit({ + bookingUid: payload.booking.uid, + actor, + organizationId: payload.organizationId, + source, + operationId, + data: auditData, + }); } - async onBookingRescheduled(payload: BookingRescheduledPayload) { + async onBookingRescheduled(params: OnBookingRescheduledParams) { + const { payload, actor, auditData, source, operationId } = params; this.log.debug("onBookingRescheduled", safeStringify(payload)); if (payload.config.isDryRun) { return; } await this.onBookingCreatedOrRescheduled(payload); + + await this.bookingAuditProducerService.queueRescheduledAudit({ + // In case of rescheduled booking, we send old booking uid because the action took place on that booking only + bookingUid: payload.oldBooking.uid, + actor, + organizationId: payload.organizationId, + source, + operationId, + data: auditData, + }); } /** @@ -80,4 +137,202 @@ export class BookingEventHandlerService { this.log.error("Error while updating hashed link", safeStringify(error)); } } + + async onBookingAccepted(params: OnBookingAcceptedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueAcceptedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onBookingCancelled(params: OnBookingCancelledParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueCancelledAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onRescheduleRequested(params: OnRescheduleRequestedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueRescheduleRequestedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onAttendeeAdded(params: OnAttendeeAddedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueAttendeeAddedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onHostNoShowUpdated(params: OnHostNoShowUpdatedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueHostNoShowUpdatedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onBookingRejected(params: OnBookingRejectedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueRejectedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onAttendeeRemoved(params: OnAttendeeRemovedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueAttendeeRemovedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onReassignment(params: OnReassignmentParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueReassignmentAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onLocationChanged(params: OnLocationChangedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueLocationChangedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onAttendeeNoShowUpdated(params: OnAttendeeNoShowUpdatedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueAttendeeNoShowUpdatedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onSeatBooked(params: OnSeatBookedParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueSeatBookedAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + async onSeatRescheduled(params: OnSeatRescheduledParams) { + const { bookingUid, actor, organizationId, auditData, source, operationId } = params; + await this.bookingAuditProducerService.queueSeatRescheduledAudit({ + bookingUid, + actor, + organizationId, + source, + operationId, + data: auditData, + }); + } + + /** + * Handles bulk booking acceptance for recurring bookings + * Creates a single task that will be processed to create multiple audit logs atomically + */ + async onBulkBookingsAccepted(params: { + bookings: Array<{ + bookingUid: string; + auditData: AcceptedAuditData; + }>; + actor: Actor; + organizationId: number | null; + operationId?: string | null; + source: ActionSource; + }) { + const { bookings, actor, organizationId, operationId, source } = params; + await this.bookingAuditProducerService.queueBulkAcceptedAudit({ + bookings: bookings.map((booking) => ({ + bookingUid: booking.bookingUid, + data: booking.auditData, + })), + actor, + organizationId, + source, + operationId, + }); + } + + /** + * Handles bulk booking cancellation for recurring bookings + * Creates a single task that will be processed to create multiple audit logs atomically + */ + async onBulkBookingsCancelled(params: { + bookings: Array<{ + bookingUid: string; + auditData: CancelledAuditData; + }>; + actor: Actor; + organizationId: number | null; + operationId?: string | null; + source: ActionSource; + }) { + const { bookings, actor, organizationId, operationId, source } = params; + await this.bookingAuditProducerService.queueBulkCancelledAudit({ + bookings: bookings.map((booking) => ({ + bookingUid: booking.bookingUid, + data: booking.auditData, + })), + actor, + organizationId, + source, + operationId, + }); + } } diff --git a/packages/features/bookings/lib/onBookingEvents/types.d.ts b/packages/features/bookings/lib/onBookingEvents/types.d.ts index c3e2c0cbce..7cfc9bc3c0 100644 --- a/packages/features/bookings/lib/onBookingEvents/types.d.ts +++ b/packages/features/bookings/lib/onBookingEvents/types.d.ts @@ -18,9 +18,11 @@ export interface BookingCreatedPayload { }; organizationId: number | null; } + export interface BookingRescheduledPayload extends BookingCreatedPayload { - oldBooking?: { + oldBooking: { + uid: string; 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 cc903e9262..12696f00fa 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -605,9 +605,9 @@ async function handler( userId: userId ?? null, eventType: eventType ? { - seatsPerTimeSlot: eventType.seatsPerTimeSlot, - minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, - } + seatsPerTimeSlot: eventType.seatsPerTimeSlot, + minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, + } : null, }); @@ -1376,9 +1376,9 @@ async function handler( // This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them. const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl ? { - bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, - conferenceCredentialId: undefined, - } + bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, + conferenceCredentialId: undefined, + } : getLocationValueForDB(locationBodyString, eventType.locations); tracingLogger.info("locationBodyString", locationBodyString); @@ -1424,8 +1424,8 @@ async function handler( const destinationCalendar = eventType.destinationCalendar ? [eventType.destinationCalendar] : organizerUser.destinationCalendar - ? [organizerUser.destinationCalendar] - : null; + ? [organizerUser.destinationCalendar] + : null; let organizerEmail = organizerUser.email || "Email-less"; if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) { @@ -2047,14 +2047,14 @@ async function handler( } const updateManager = !skipCalendarSyncTaskCreation ? await eventManager.reschedule( - evt, - originalRescheduledBooking.uid, - undefined, - changedOrganizer, - previousHostDestinationCalendar, - isBookingRequestedReschedule, - skipDeleteEventsAndMeetings - ) + evt, + originalRescheduledBooking.uid, + undefined, + changedOrganizer, + previousHostDestinationCalendar, + isBookingRequestedReschedule, + skipDeleteEventsAndMeetings + ) : placeholderCreatedEvent; // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. @@ -2355,8 +2355,8 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, - } + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, + } : undefined; const bookingCreatedPayload = buildBookingCreatedPayload({ @@ -2368,25 +2368,53 @@ async function handler( organizationId: eventOrganizationId, }); - const bookingRescheduledPayload: BookingRescheduledPayload = { - ...bookingCreatedPayload, - oldBooking: originalRescheduledBooking - ? { - startTime: originalRescheduledBooking.startTime, - endTime: originalRescheduledBooking.endTime, - } - : undefined, - }; - const bookingEventHandler = deps.bookingEventHandler; + // TODO: Identify action source correctly + const actionSource = 'WEBAPP'; + // TODO: We need to check session in booking flow and accordingly create USER actor if applicable. + const auditActor = makeGuestActor({ email: bookerEmail, name: fullName }); - // TODO: Incrementally move all stuff that happens after a booking is created to these handlers if (originalRescheduledBooking) { - await bookingEventHandler.onBookingRescheduled(bookingRescheduledPayload); + const bookingRescheduledPayload: BookingRescheduledPayload = { + ...bookingCreatedPayload, + oldBooking: { + uid: originalRescheduledBooking.uid, + startTime: originalRescheduledBooking.startTime, + endTime: originalRescheduledBooking.endTime, + }, + }; + await bookingEventHandler.onBookingRescheduled({ + payload: bookingRescheduledPayload, + actor: auditActor, + auditData: { + startTime: { + old: bookingRescheduledPayload.oldBooking?.startTime.toISOString() ?? null, + new: bookingRescheduledPayload.booking.startTime.toISOString(), + }, + endTime: { + old: bookingRescheduledPayload.oldBooking?.endTime.toISOString() ?? null, + new: bookingRescheduledPayload.booking.endTime.toISOString(), + }, + rescheduledToUid: { + old: null, + new: bookingRescheduledPayload.booking.uid, + }, + }, + source: actionSource, + operationId: null, + }); } else { - // TODO: We need to check session in booking flow and accordingly create USER actor if applicable. - const auditActor = makeGuestActor({ email: bookerEmail, name: fullName }); - await bookingEventHandler.onBookingCreated(bookingCreatedPayload, auditActor); + await bookingEventHandler.onBookingCreated({ + payload: bookingCreatedPayload, + actor: auditActor, + auditData: { + startTime: bookingCreatedPayload.booking.startTime.getTime(), + endTime: bookingCreatedPayload.booking.endTime.getTime(), + status: bookingCreatedPayload.booking.status, + }, + source: actionSource, + operationId: null, + }); } const webhookData: EventPayloadType = { @@ -2451,9 +2479,9 @@ async function handler( ...eventType, metadata: eventType.metadata ? { - ...eventType.metadata, - apps: eventType.metadata?.apps as Prisma.JsonValue, - } + ...eventType.metadata, + apps: eventType.metadata?.apps as Prisma.JsonValue, + } : {}, }, paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, @@ -2791,7 +2819,7 @@ async function handler( * We are open to renaming it to something more descriptive. */ export class RegularBookingService implements IBookingService { - constructor(private readonly deps: IBookingServiceDependencies) {} + constructor(private readonly deps: IBookingServiceDependencies) { } async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); diff --git a/packages/features/bookings/lib/types/actor.ts b/packages/features/bookings/lib/types/actor.ts index 0469462a62..0f613c4b14 100644 --- a/packages/features/bookings/lib/types/actor.ts +++ b/packages/features/bookings/lib/types/actor.ts @@ -5,13 +5,6 @@ const UserActorSchema = z.object({ userUuid: z.string(), }); -const GuestActorSchema = z.object({ - identifiedBy: z.literal("guest"), - email: z.string().optional(), - name: z.string().optional(), - phone: z.string().optional(), -}); - const AttendeeActorSchema = z.object({ identifiedBy: z.literal("attendee"), attendeeId: z.number(), @@ -22,19 +15,47 @@ const ActorByIdSchema = z.object({ id: z.string(), }); +const GuestActorSchema = z.object({ + identifiedBy: z.literal("guest"), + email: z.string(), + name: z.string().nullable(), +}); + +const AppActorByCredentialIdSchema = z.object({ + identifiedBy: z.literal("app"), + credentialId: z.number(), +}); + +const AppActorBySlugSchema = z.object({ + identifiedBy: z.literal("appSlug"), + appSlug: z.string(), + name: z.string(), +}); + export const ActorSchema = z.discriminatedUnion("identifiedBy", [ ActorByIdSchema, UserActorSchema, + AttendeeActorSchema, GuestActorSchema, + AppActorByCredentialIdSchema, + AppActorBySlugSchema, +]); + +export const PiiFreeActorSchema = z.discriminatedUnion("identifiedBy", [ + ActorByIdSchema, + UserActorSchema, AttendeeActorSchema, ]); export type Actor = z.infer; +export type PiiFreeActor = z.infer; + type UserActor = z.infer; type GuestActor = z.infer; type AttendeeActor = z.infer; type ActorById = z.infer; - +type AppActorByCredentialId = z.infer; +type AppActorBySlug = z.infer; /** * Creates an Actor representing a User by UUID */ @@ -45,6 +66,14 @@ export function makeUserActor(userUuid: string): UserActor { }; } +export function makeGuestActor({ email, name }: { email: string, name: string | null }): GuestActor { + return { + identifiedBy: "guest", + email, + name: name ?? null, + }; +} + /** * Creates an Actor representing the System * System actors must be referenced by ID (requires migration) @@ -56,21 +85,6 @@ export function makeSystemActor(): ActorById { }; } -/** - * Creates an Actor representing a Guest (non-registered attendee) - */ -export function makeGuestActor(params: { - email: string; - name?: string; - phone?: string; -}): GuestActor { - return { - identifiedBy: "guest", - email: params.email, - name: params.name, - phone: params.phone, - }; -} /** * Creates an Actor by existing actor ID @@ -92,5 +106,33 @@ export function makeAttendeeActor(attendeeId: number): AttendeeActor { }; } -// System actor ID constant +/** + * Creates an Actor representing an App by credential ID (preferred) + * The credentialId uniquely identifies which app instance (e.g., which Stripe account) + * App name and slug are derived from the credential at display time + */ +export function makeAppActor(params: { credentialId: number }): AppActorByCredentialId { + return { + identifiedBy: "app", + credentialId: params.credentialId, + }; +} + +/** + * Creates an Actor representing an App by app slug (fallback) + * Used when credentialId is not available or for apps not yet migrated + * App actors use @app.internal email convention + */ +export function makeAppActorUsingSlug(params: { appSlug: string; name: string }): AppActorBySlug { + return { + identifiedBy: "appSlug", + appSlug: params.appSlug, + name: params.name, + }; +} + +export function buildActorEmail({ identifier, actorType }: { identifier: string, actorType: "system" | "guest" | "app" }): string { + return `${identifier}@${actorType}.internal`; +} + export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000"; \ No newline at end of file diff --git a/packages/features/bookings/repositories/AttendeeRepository.ts b/packages/features/bookings/repositories/AttendeeRepository.ts new file mode 100644 index 0000000000..8803f1c7e0 --- /dev/null +++ b/packages/features/bookings/repositories/AttendeeRepository.ts @@ -0,0 +1,24 @@ +import type { PrismaClient } from "@calcom/prisma"; +import type { IAttendeeRepository } from "./IAttendeeRepository"; + +/** + * Prisma-based implementation of IAttendeeRepository + * + * This repository provides methods for looking up attendee information. + */ +export class AttendeeRepository implements IAttendeeRepository { + constructor(private prismaClient: PrismaClient) {} + + async findById(id: number): Promise<{ name: string; email: string } | null> { + const attendee = await this.prismaClient.attendee.findUnique({ + where: { id }, + select: { + name: true, + email: true, + }, + }); + + return attendee; + } +} + diff --git a/packages/features/bookings/repositories/IAttendeeRepository.ts b/packages/features/bookings/repositories/IAttendeeRepository.ts new file mode 100644 index 0000000000..f35cbf3378 --- /dev/null +++ b/packages/features/bookings/repositories/IAttendeeRepository.ts @@ -0,0 +1,9 @@ +/** + * Attendee lookup interface + * + * This interface provides methods for looking up attendee information. + */ +export interface IAttendeeRepository { + findById(id: number): Promise<{ name: string; email: string } | null>; +} + diff --git a/packages/features/credentials/di/CredentialRepository.module.ts b/packages/features/credentials/di/CredentialRepository.module.ts new file mode 100644 index 0000000000..59777d006a --- /dev/null +++ b/packages/features/credentials/di/CredentialRepository.module.ts @@ -0,0 +1,22 @@ +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; + +import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "@calcom/features/di/di"; + +export const credentialRepositoryModule = createModule(); +const token = DI_TOKENS.CREDENTIAL_REPOSITORY; +const moduleToken = DI_TOKENS.CREDENTIAL_REPOSITORY_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: credentialRepositoryModule, + moduleToken, + token, + classs: CredentialRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; diff --git a/packages/features/credentials/repositories/CredentialRepository.ts b/packages/features/credentials/repositories/CredentialRepository.ts index d685a469c5..e4f4ab4ead 100644 --- a/packages/features/credentials/repositories/CredentialRepository.ts +++ b/packages/features/credentials/repositories/CredentialRepository.ts @@ -25,7 +25,14 @@ type CredentialUpdateInput = { }; export class CredentialRepository { - constructor(private primaClient: PrismaClient) {} + constructor(private primaClient: PrismaClient) { } + + async findByCredentialId(id: number) { + return this.primaClient.credential.findUnique({ + where: { id }, + select: safeCredentialSelect, + }); + } async findByIdWithDelegationCredential(id: number) { return this.primaClient.credential.findUnique({ diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 0dd2a7b93d..23e67ecf00 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -35,6 +35,8 @@ export enum CustomAction { ReadRecordings = "readRecordings", Impersonate = "impersonate", EditUsers = "editUsers", + ReadTeamAuditLogs = "readTeamAuditLogs", + ReadOrgAuditLogs = "readOrgAuditLogs", } export enum Scope { @@ -482,6 +484,22 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { descriptionI18nKey: "pbac_desc_update_bookings", dependsOn: ["booking.read"], }, + [CustomAction.ReadTeamAuditLogs]: { + description: "View team booking audit logs", + category: "booking", + i18nKey: "pbac_action_read_team_audit_logs", + descriptionI18nKey: "pbac_desc_view_team_booking_audit_logs", + scope: [Scope.Team], + dependsOn: ["booking.read"], + }, + [CustomAction.ReadOrgAuditLogs]: { + description: "View organization booking audit logs", + category: "booking", + i18nKey: "pbac_action_read_org_audit_logs", + descriptionI18nKey: "pbac_desc_view_organization_booking_audit_logs", + scope: [Scope.Organization], + dependsOn: ["booking.read"], + }, }, [Resource.Insights]: { _resource: { diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index 7f44770fc0..d66d2650da 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -1,7 +1,7 @@ import type { z } from "zod"; import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; -import type { BookingAuditTaskBasePayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask"; +import type { BookingAuditTaskConsumerPayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask"; export type TaskerTypes = "internal" | "redis"; type TaskPayloads = { sendWebhook: string; @@ -37,7 +37,7 @@ type TaskPayloads = { responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null; routedEventTypeId?: number | null; }; - bookingAudit: BookingAuditTaskBasePayload; + bookingAudit: BookingAuditTaskConsumerPayload; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string, taskId?: string) => Promise; diff --git a/packages/features/tasker/tasks/bookingAudit.ts b/packages/features/tasker/tasks/bookingAudit.ts index 3184ef498e..04722ff813 100644 --- a/packages/features/tasker/tasks/bookingAudit.ts +++ b/packages/features/tasker/tasks/bookingAudit.ts @@ -1,6 +1,7 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getBookingAuditTaskConsumer } from "@calcom/features/booking-audit/di/BookingAuditTaskConsumer.container"; +import { BookingAuditTaskConsumerSchema } from "@calcom/features/booking-audit/lib/types/bookingAuditTask"; const log = logger.getSubLogger({ prefix: ["[tasker] bookingAudit"] }); @@ -8,7 +9,7 @@ const log = logger.getSubLogger({ prefix: ["[tasker] bookingAudit"] }); * Booking Audit Task Handler * * Thin wrapper that parses JSON and delegates to BookingAuditTaskConsumer. - * Schema validation happens in processAuditTask for better separation of concerns. + * Routes to processAuditTask or processBulkAuditTask based on isBulk field. * */ export async function bookingAudit(payload: string, taskId?: string): Promise { @@ -18,13 +19,22 @@ export async function bookingAudit(payload: string, taskId?: string): Promise { + switch (code) { + case BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED: + return t("audit_logs_organization_required"); + case BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED: + return t("audit_logs_booking_not_found_or_permission_denied"); + case BookingAuditErrorCode.BOOKING_HAS_NO_OWNER: + return t("audit_logs_booking_has_no_owner"); + case BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION: + return t("audit_logs_owner_not_in_organization"); + case BookingAuditErrorCode.PERMISSION_DENIED: + return t("audit_logs_permission_denied"); + default: + return t("audit_logs_permission_check_error"); + } +}; + export const getAuditLogsHandler = async ({ ctx, input }: GetAuditLogsOptions) => { const { user } = ctx; const { bookingUid } = input; + const t = await getTranslation(user.locale ?? "en", "common"); const bookingAuditViewerService = getBookingAuditViewerService(); - const result = await bookingAuditViewerService.getAuditLogsForBooking( - bookingUid, - user.id, - user.email - ); + try { + const result = await bookingAuditViewerService.getAuditLogsForBooking({ + bookingUid, + userId: user.id, + userEmail: user.email, + userTimeZone: user.timeZone, + organizationId: user.organizationId, + }); - return result; + return result; + } catch (error) { + if (error instanceof BookingAuditPermissionError) { + throw new TRPCError({ + code: "FORBIDDEN", + message: getErrorMessage(error.code, t), + }); + } + throw error; + } };