From b74dd3227caf39bafaefa1f467f69bb008e3bb66 Mon Sep 17 00:00:00 2001 From: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:08:38 +0530 Subject: [PATCH] perf: Optimize DB calls and avoid N+1 queries in BookingAuditViewer (#26544) * Integrate creation/rescheduling booking audit * fix: add missing hostUserUuid to booking audit test data Co-Authored-By: hariom@cal.com * fix-ci * feat: enhance booking audit with seat reference - Added support for seat reference in booking audit actions. - Updated localization for booking creation to include seat information. - Modified relevant services to pass attendee seat ID during booking creation. * fix: update test data to match schema requirements - Add seatReferenceUid: null to default mock audit log data - Add seatReferenceUid: null to multiple audit logs test case - Convert SEAT_RESCHEDULED test data to use numeric timestamps instead of ISO strings Co-Authored-By: hariom@cal.com * Allow nullish seatReferenceUid * feat: enhance booking audit to support rescheduledBy information - Updated booking audit actions to include rescheduledBy details, allowing tracking of who rescheduled a booking. - Refactored related services to accommodate the new rescheduledBy parameter in booking events. - Adjusted type definitions and function signatures to reflect the changes in the booking audit context. * Avoid possible run time issue * Fix imoport path * fix failing test due to merge from main\ * Pass useruuid * Refactor getDisplayTitle method signatures to use _params for consistency across audit action services. Update IAuditActionService to include dbStore in GetDisplayTitleParams for improved data handling during title retrieval. * fix: extend bulk-fetching optimization to actor and impersonator enrichment - Collect actor userUuids (for USER type actors) in collectDataRequirements - Collect impersonator UUIDs from log.context.impersonatedBy - Update enrichActorInformation to use dbStore.getUserByUuid() for USER actors - Update enrichImpersonator to use dbStore.getUserByUuid() - Update tests to verify findByUuids is called with correct aggregated UUIDs Co-Authored-By: hariom@cal.com * feat: Add infrastructure for no-show audit integration - Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action - Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema - Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED - Update BookingAuditTaskConsumer and BookingAuditViewerService - Add AttendeeRepository methods for no-show queries - Update IAuditActionService interface with values array support - Update locales with no-show audit translation keys Co-Authored-By: hariom@cal.com * fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types Co-Authored-By: hariom@cal.com * fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema Co-Authored-By: hariom@cal.com * fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED Co-Authored-By: hariom@cal.com * refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService - Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService - Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit - Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit - Update BookingEventHandlerService.ts to use onNoShowUpdated - Add integration tests for NoShowUpdatedAuditActionService Co-Authored-By: hariom@cal.com * fix: Add data migration step for deprecated no-show enum values Addresses Cubic AI review feedback (confidence 9/10): The migration now includes an UPDATE statement to convert existing records using the deprecated 'host_no_show_updated' or 'attendee_no_show_updated' enum values to the new unified 'no_show_updated' value before the type cast. This prevents migration failures if any existing data uses the old values. Co-Authored-By: unknown <> * fix: Use CASE expression in USING clause for enum migration Fixes PostgreSQL error 'unsafe use of new value of enum type' by avoiding the ADD VALUE statement and instead using a CASE expression in the ALTER TABLE USING clause to convert deprecated enum values (host_no_show_updated, attendee_no_show_updated) to the new unified value (no_show_updated) during the type conversion. Co-Authored-By: unknown <> * fix: Remove dbStore usage from audit action services after merge - Update CreatedAuditActionService to use userRepository.findByUuid directly - Remove unused dbStore parameter from RescheduledAuditActionService - Sync test file with prepare-for-no-show-audit branch Co-Authored-By: hariom@cal.com * fix: Restore dbStore bulk-fetching optimization pattern - Add dbStore parameter and getDataRequirements method to IAuditActionService interface - Update CreatedAuditActionService to use dbStore.getUserByUuid() for bulk-fetched data - Update RescheduledAuditActionService to accept dbStore parameter - Update BookingAuditViewerService to: - Collect data requirements from all audit logs - Bulk-fetch users with findByUuids() before enrichment - Pass dbStore to action services and enrichment methods - Update tests to verify findByUuids is called for bulk-fetching optimization Co-Authored-By: hariom@cal.com * fix: Remove duplicate fireBookingEvents method in RecurringBookingService Co-Authored-By: hariom@cal.com * revert: Remove formatting-only changes in audit action services Co-Authored-By: hariom@cal.com * feat: Make getDataRequirements required and fetch rescheduled logs early - Make getDataRequirements a required method in IAuditActionService interface - Add getDataRequirements to all action services that didn't have it - Fetch rescheduled logs early to include their data requirements in bulk fetch - Update ReassignmentAuditActionService to use dbStore pattern instead of repository - Update NoShowUpdatedAuditActionService to use dbStore pattern - Update tests to use new dbStore pattern - Handle invalid data gracefully in collectDataRequirements Co-Authored-By: hariom@cal.com * Remve unncessary variable * feat: Implement Actor Strategy pattern for bulk-fetching attendees and credentials - Add ActorStrategies.ts with strategy pattern for all 5 actor types - Extend EnrichmentDataStore with StoredAttendee and StoredCredential types - Add findByIds method to CredentialRepository for bulk-fetching - Update BookingAuditViewerService to use strategy pattern: - Replace switch statement in enrichActorInformation with strategy dispatch - Update collectDataRequirements to collect attendeeIds and credentialIds - Update buildEnrichmentDataStore to bulk-fetch in parallel - Update tests to use findByIds mock and reflect graceful handling of missing data Co-Authored-By: hariom@cal.com * fix: Restore error-throwing for actors with missing required IDs - USER actor now throws error when userUuid is missing - ATTENDEE actor now throws error when attendeeId is missing - Updated tests to expect hasError: true for fallback logs Co-Authored-By: hariom@cal.com * fix: Restore original test assertions, only update mocks for bulk methods Co-Authored-By: hariom@cal.com * test: Add contract verification tests for getDataRequirements Co-Authored-By: hariom@cal.com * refactor: Clean up imports and improve type definitions in BookingAuditTaskConsumer and BookingAuditViewerService - Consolidated import statements for better readability - Enhanced type definitions for clarity and consistency - Updated method signatures to use destructured parameters for improved maintainability Co-Authored-By: hariom@cal.com * test: Split contract verification tests into separate files per action service Co-Authored-By: hariom@cal.com * test: Rename contract test files to .test.ts and merge into existing test file Co-Authored-By: hariom@cal.com * feat: Add runtime validation to EnrichmentDataStore for undeclared data access Co-Authored-By: hariom@cal.com * refactor: Make declaredRequirements required and add ensureFetched helper Co-Authored-By: hariom@cal.com * refactor: Simplify EnrichmentDataStore with constructor + fetch() pattern - Remove unused userIds and bookingUids from DataRequirements - Remove unused getUserById and getBookingByUid methods - Refactor constructor to accept requirements and repositories - Add fetch() method that fetches data and populates maps - Use Map keys as source of truth for declared requirements - Update getters to throw when accessing undeclared data - Add comprehensive unit tests for EnrichmentDataStore - Update BookingAuditViewerService to use new API - Update contractVerification.ts to remove unused tracking Co-Authored-By: hariom@cal.com * refactor: Clean up imports and improve type definitions in BookingAuditTaskConsumer and BookingAuditViewerService - Consolidated import statements for better readability - Enhanced type definitions for clarity and consistency - Updated method signatures to use destructured parameters for improved maintainability Co-Authored-By: hariom@cal.com * fix: Include contextRequirements in mergeDataRequirements and add accessedData assertions - Fix bug where contextRequirements (impersonator UUIDs) were not merged - Add accessedData assertions to CreatedAuditActionService.test.ts - Add accessedData assertions to ReassignmentAuditActionService.test.ts Co-Authored-By: hariom@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../lib/actions/AcceptedAuditActionService.ts | 5 + .../AttendeeAddedAuditActionService.ts | 5 + .../AttendeeRemovedAuditActionService.ts | 5 + .../actions/CancelledAuditActionService.ts | 7 +- .../lib/actions/CreatedAuditActionService.ts | 20 +- .../lib/actions/IAuditActionService.ts | 12 + .../LocationChangedAuditActionService.ts | 5 + .../NoShowUpdatedAuditActionService.ts | 30 +- .../actions/ReassignmentAuditActionService.ts | 55 +- .../lib/actions/RejectedAuditActionService.ts | 5 + .../RescheduleRequestedAuditActionService.ts | 5 + .../actions/RescheduledAuditActionService.ts | 5 + .../actions/SeatBookedAuditActionService.ts | 132 +-- .../SeatRescheduledAuditActionService.ts | 194 ++--- .../AcceptedAuditActionService.test.ts | 23 + .../AttendeeAddedAuditActionService.test.ts | 25 + .../AttendeeRemovedAuditActionService.test.ts | 25 + .../CancelledAuditActionService.test.ts | 26 + .../CreatedAuditActionService.test.ts | 61 ++ .../LocationChangedAuditActionService.test.ts | 25 + .../NoShowUpdatedAuditActionService.test.ts | 58 ++ .../ReassignmentAuditActionService.test.ts | 156 +++- .../RejectedAuditActionService.test.ts | 25 + ...cheduleRequestedAuditActionService.test.ts | 26 + .../RescheduledAuditActionService.test.ts | 28 + .../SeatBookedAuditActionService.test.ts | 29 + .../SeatRescheduledAuditActionService.test.ts | 29 + .../actions/__tests__/contractVerification.ts | 184 ++++ .../lib/service/ActorStrategies.ts | 95 +++ .../BookingAuditActionServiceRegistry.ts | 15 +- .../lib/service/BookingAuditTaskConsumer.ts | 784 +++++++++--------- .../lib/service/BookingAuditViewerService.ts | 199 +++-- .../lib/service/EnrichmentDataStore.ts | 190 +++++ .../BookingAuditViewerService.test.ts | 37 +- .../__tests__/EnrichmentDataStore.test.ts | 340 ++++++++ .../repositories/CredentialRepository.ts | 8 + .../users/repositories/UserRepository.ts | 19 + 37 files changed, 2157 insertions(+), 735 deletions(-) create mode 100644 packages/features/booking-audit/lib/actions/__tests__/AcceptedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/AttendeeAddedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/AttendeeRemovedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/CancelledAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/CreatedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/LocationChangedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/NoShowUpdatedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/RejectedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/RescheduleRequestedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/RescheduledAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/SeatBookedAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/SeatRescheduledAuditActionService.test.ts create mode 100644 packages/features/booking-audit/lib/actions/__tests__/contractVerification.ts create mode 100644 packages/features/booking-audit/lib/service/ActorStrategies.ts create mode 100644 packages/features/booking-audit/lib/service/EnrichmentDataStore.ts create mode 100644 packages/features/booking-audit/lib/service/__tests__/EnrichmentDataStore.test.ts diff --git a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts index f939272547..6b66679738 100644 --- a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts @@ -2,6 +2,7 @@ import type { BookingStatus } from "@calcom/prisma/enums"; import { z } from "zod"; import { BookingStatusChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; @@ -59,6 +60,10 @@ export class AcceptedAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.accepted" }; } diff --git a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts index 9802a3e278..0fb58f8c48 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { emailSchema } from "@calcom/lib/emailSchema"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, @@ -63,6 +64,10 @@ export class AttendeeAddedAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.attendee_added" }; } diff --git a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts index 86062de959..82b23c1e96 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { StringArrayChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; @@ -58,6 +59,10 @@ export class AttendeeRemovedAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.attendee_removed" }; } diff --git a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts index 4dcb6f5dfa..2c9a1d6be9 100644 --- a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -import { StringChangeSchema, BookingStatusChangeSchema } from "../common/changeSchemas"; +import { BookingStatusChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; @@ -60,6 +61,10 @@ export class CancelledAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.cancelled" }; } diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index a2109ddd4c..fff749b7a8 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { BookingStatus } from "@calcom/prisma/enums"; -import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; /** * Created Audit Action Service @@ -21,9 +21,6 @@ const fieldsSchemaV1 = z.object({ seatReferenceUid: z.string().nullish(), }); -type Deps = { - userRepository: UserRepository; -}; export class CreatedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "CREATED" as const; @@ -39,7 +36,7 @@ export class CreatedAuditActionService implements IAuditActionService { public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1; private helper: AuditActionServiceHelper; - constructor(private readonly deps: Deps) { + constructor() { this.helper = new AuditActionServiceHelper({ latestVersion: this.VERSION, latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema, @@ -65,9 +62,16 @@ export class CreatedAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } - async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise { + getDataRequirements(storedData: BaseStoredAuditData): DataRequirements { const { fields } = this.parseStored(storedData); - const hostUser = fields.hostUserUuid ? await this.deps.userRepository.findByUuid({ uuid: fields.hostUserUuid }) : null; + return { + userUuids: fields.hostUserUuid ? [fields.hostUserUuid] : [], + }; + } + + async getDisplayTitle({ storedData, dbStore }: GetDisplayTitleParams): Promise { + const { fields } = this.parseStored(storedData); + const hostUser = fields.hostUserUuid ? dbStore.getUserByUuid(fields.hostUserUuid) : null; const hostName = hostUser?.name || "Unknown"; if (fields.seatReferenceUid) { return { key: "booking_audit_action.created_with_seat", params: { host: hostName } }; diff --git a/packages/features/booking-audit/lib/actions/IAuditActionService.ts b/packages/features/booking-audit/lib/actions/IAuditActionService.ts index e0d5f0d6a6..c9e3209b37 100644 --- a/packages/features/booking-audit/lib/actions/IAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/IAuditActionService.ts @@ -18,6 +18,8 @@ export type TranslationWithParams = { components?: TranslationComponent[]; }; +import type { EnrichmentDataStore, DataRequirements } from "../service/EnrichmentDataStore"; + /** * This is agnostic of the action and is common for all actions */ @@ -34,10 +36,12 @@ export type GetDisplayJsonParams = { export type GetDisplayTitleParams = { storedData: BaseStoredAuditData; userTimeZone: string; + dbStore: EnrichmentDataStore; }; export type GetDisplayFieldsParams = { storedData: BaseStoredAuditData; + dbStore: EnrichmentDataStore; }; /** @@ -88,6 +92,14 @@ export interface IAuditActionService { */ getDisplayJson?(params: GetDisplayJsonParams): Record; + /** + * Declare what data this action needs from DB + * Returns identifiers to be bulk-fetched before enrichment + * @param storedData - Parsed stored data { version, fields } + * @returns Data requirements with arrays of identifiers to fetch + */ + getDataRequirements(storedData: BaseStoredAuditData): DataRequirements; + /** * Get the display title for the audit action * Returns a translation key with optional interpolation params for dynamic titles diff --git a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts index 947848b4bb..0e7b869c62 100644 --- a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { getHumanReadableLocationValue } from "@calcom/app-store/locations"; import { StringChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, @@ -63,6 +64,10 @@ export class LocationChangedAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } + async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise { const { fields } = this.parseStored(storedData); // TODO: Ideally we want to translate the location label to the user's locale diff --git a/packages/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService.ts index 433eebaa94..91f5f9a3d7 100644 --- a/packages/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService.ts @@ -1,10 +1,10 @@ -import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; -import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import type { Ensure } from "@calcom/types/utils"; import { z } from "zod"; import { BooleanChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { + BaseStoredAuditData, GetDisplayFieldsParams, GetDisplayJsonParams, GetDisplayTitleParams, @@ -31,11 +31,6 @@ const fieldsSchemaV1 = z message: "At least one of host or attendeesNoShow must be provided", }); -type Deps = { - attendeeRepository: IAttendeeRepository; - userRepository: UserRepository; -}; - export class NoShowUpdatedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "NO_SHOW_UPDATED" as const; @@ -54,7 +49,7 @@ export class NoShowUpdatedAuditActionService implements IAuditActionService { typeof NoShowUpdatedAuditActionService.storedDataSchema >; - constructor(private readonly deps: Deps) { + constructor() { this.helper = new AuditActionServiceHelper({ latestVersion: this.VERSION, latestFieldsSchema: NoShowUpdatedAuditActionService.latestFieldsSchema, @@ -90,9 +85,17 @@ export class NoShowUpdatedAuditActionService implements IAuditActionService { return fields.attendeesNoShow !== undefined; } + getDataRequirements(storedData: BaseStoredAuditData): DataRequirements { + const { fields: parsedFields } = this.parseStored(storedData); + const userUuids: string[] = []; + if (this.isHostSet(parsedFields)) { + userUuids.push(parsedFields.host.userUuid); + } + return { userUuids }; + } + async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); - const parsedFields = fields as NoShowUpdatedAuditData; + const { fields: parsedFields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); if (this.isHostSet(parsedFields) && this.isAttendeesNoShowSet(parsedFields)) { return { key: "booking_audit_action.no_show_updated" }; } @@ -105,7 +108,7 @@ export class NoShowUpdatedAuditActionService implements IAuditActionService { throw new Error("Audit action data is invalid"); } - async getDisplayFields({ storedData }: GetDisplayFieldsParams): Promise< + async getDisplayFields({ storedData, dbStore }: GetDisplayFieldsParams): Promise< Array<{ labelKey: string; valueKey?: string; @@ -113,8 +116,7 @@ export class NoShowUpdatedAuditActionService implements IAuditActionService { values?: string[]; }> > { - const { fields } = this.parseStored(storedData); - const parsedFields = fields as NoShowUpdatedAuditData; + const { fields: parsedFields } = this.parseStored(storedData); const displayFields: { labelKey: string; valueKey?: string; value?: string; values?: string[] }[] = []; if (this.isAttendeesNoShowSet(parsedFields)) { @@ -126,7 +128,7 @@ export class NoShowUpdatedAuditActionService implements IAuditActionService { } if (this.isHostSet(parsedFields)) { - const user = await this.deps.userRepository.findByUuid({ uuid: parsedFields.host.userUuid }); + const user = dbStore.getUserByUuid(parsedFields.host.userUuid); const hostName = user?.name || "Unknown"; const hostFieldValue = `${hostName}:${parsedFields.host.noShow.new ? "Yes" : "No"}`; displayFields.push({ labelKey: "Host", value: hostFieldValue }); diff --git a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts index 05c73748aa..20457fe488 100644 --- a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts @@ -1,8 +1,9 @@ -import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { z } from "zod"; import { StringChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements, EnrichmentDataStore } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { + BaseStoredAuditData, GetDisplayFieldsParams, GetDisplayJsonParams, GetDisplayTitleParams, @@ -46,7 +47,7 @@ export class ReassignmentAuditActionService implements IAuditActionService { typeof ReassignmentAuditActionService.storedDataSchema >; - constructor(private userRepository: UserRepository) { + constructor() { this.helper = new AuditActionServiceHelper({ latestVersion: this.VERSION, latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema, @@ -72,9 +73,30 @@ export class ReassignmentAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } - async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise { + getDataRequirements(storedData: BaseStoredAuditData): DataRequirements { const { fields } = this.parseStored(storedData); - const { newUser } = await this.getPreviousAndNewAssigneeUser(fields); + const userUuids: string[] = []; + const { newHostUuid, previousHostUuid } = this.getHostUuids(fields); + if (newHostUuid) userUuids.push(newHostUuid); + if (previousHostUuid) userUuids.push(previousHostUuid); + return { userUuids }; + } + + private getHostUuids(fields: ReassignmentAuditData) { + const hasAttendeeUpdated = fields.hostAttendeeUpdated != null; + const newHostUuid = hasAttendeeUpdated + ? fields.hostAttendeeUpdated?.withUserUuid?.new + : fields.organizerUuid?.new; + const previousHostUuid = hasAttendeeUpdated + ? fields.hostAttendeeUpdated?.withUserUuid?.old + : fields.organizerUuid?.old; + return { newHostUuid, previousHostUuid }; + } + + async getDisplayTitle({ storedData, dbStore }: GetDisplayTitleParams): Promise { + const { fields } = this.parseStored(storedData); + const { newHostUuid } = this.getHostUuids(fields); + const newUser = newHostUuid ? dbStore.getUserByUuid(newHostUuid) : null; const reassignedToName = newUser?.name || "Unknown"; return { key: "booking_audit_action.booking_reassigned_to_host", @@ -103,27 +125,7 @@ export class ReassignmentAuditActionService implements IAuditActionService { }; } - private async getPreviousAndNewAssigneeUser(fields: ReassignmentAuditData) { - const hasAttendeeUpdated = fields.hostAttendeeUpdated != null; - const newHostUuid = hasAttendeeUpdated - ? fields.hostAttendeeUpdated?.withUserUuid?.new - : fields.organizerUuid?.new; - const previousHostUuid = hasAttendeeUpdated - ? fields.hostAttendeeUpdated?.withUserUuid?.old - : fields.organizerUuid?.old; - - const newUser = newHostUuid ? await this.userRepository.findByUuid({ uuid: newHostUuid }) : null; - const previousUser = previousHostUuid - ? await this.userRepository.findByUuid({ uuid: previousHostUuid }) - : null; - - return { - previousUser: previousUser, - newUser: newUser, - }; - } - - async getDisplayFields({ storedData }: GetDisplayFieldsParams): Promise< + async getDisplayFields({ storedData, dbStore }: GetDisplayFieldsParams): Promise< Array<{ labelKey: string; valueKey: string; @@ -135,7 +137,8 @@ export class ReassignmentAuditActionService implements IAuditActionService { roundRobin: "round_robin", }; const typeTranslationKey = `booking_audit_action.assignment_type_${map[fields.reassignmentType]}`; - const { previousUser } = await this.getPreviousAndNewAssigneeUser(fields); + const { previousHostUuid } = this.getHostUuids(fields); + const previousUser = previousHostUuid ? dbStore.getUserByUuid(previousHostUuid) : null; return [ { labelKey: "booking_audit_action.assignment_type", diff --git a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts index 4c7ae3acab..072832f6b4 100644 --- a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { BookingStatusChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, @@ -65,6 +66,10 @@ export class RejectedAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.rejected" }; } diff --git a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts index 0da44589f8..b163937f00 100644 --- a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, @@ -63,6 +64,10 @@ export class RescheduleRequestedAuditActionService implements IAuditActionServic return { isMigrated: false, latestData: validated }; } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } + async getDisplayTitle(_: GetDisplayTitleParams): Promise { return { key: "booking_audit_action.reschedule_requested" }; } diff --git a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts index 29997ee14b..f5ea5cd69c 100644 --- a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { NumberChangeSchema, StringChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService"; @@ -60,6 +61,10 @@ export class RescheduledAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } + async getDisplayTitle({ storedData, userTimeZone, diff --git a/packages/features/booking-audit/lib/actions/SeatBookedAuditActionService.ts b/packages/features/booking-audit/lib/actions/SeatBookedAuditActionService.ts index 0bc942c9df..3963c990c6 100644 --- a/packages/features/booking-audit/lib/actions/SeatBookedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/SeatBookedAuditActionService.ts @@ -1,91 +1,101 @@ import { z } from "zod"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { GetDisplayTitleParams, GetDisplayJsonParams, IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +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(), + 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, + readonly VERSION = 1; + public static readonly TYPE = "SEAT_BOOKED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = SeatBookedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = SeatBookedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof SeatBookedAuditActionService.latestFieldsSchema, + typeof SeatBookedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: SeatBookedAuditActionService.latestFieldsSchema, + storedDataSchema: SeatBookedAuditActionService.storedDataSchema, }); - 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); + } - getVersionedData(fields: unknown) { - return this.helper.getVersionedData(fields); - } + parseStored(data: unknown) { + return this.helper.parseStored(data); + } - parseStored(data: unknown) { - return this.helper.parseStored(data); - } + getVersion(data: unknown): number { + return this.helper.getVersion(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 }; + } - migrateToLatest(data: unknown) { - // V1-only: validate and return as-is (no migration needed) - const validated = fieldsSchemaV1.parse(data); - return { isMigrated: false, latestData: validated }; - } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } - async getDisplayTitle(_: GetDisplayTitleParams): Promise { - return { key: "booking_audit_action.seat_booked" }; - } + async getDisplayTitle(_: GetDisplayTitleParams): Promise { + return { key: "booking_audit_action.seat_booked" }; + } - getDisplayJson({ - storedData, - userTimeZone, - }: GetDisplayJsonParams): SeatBookedAuditDisplayData { - const { fields } = this.parseStored(storedData); + 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), - }; - } + 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; + 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 index ea64f36162..55958c0d71 100644 --- a/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts @@ -1,8 +1,14 @@ import { z } from "zod"; import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas"; +import type { DataRequirements } from "../service/EnrichmentDataStore"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams, TranslationWithParams } from "./IAuditActionService"; +import type { + IAuditActionService, + GetDisplayTitleParams, + GetDisplayJsonParams, + TranslationWithParams, +} from "./IAuditActionService"; /** * Seat Rescheduled Audit Action Service * Handles SEAT_RESCHEDULED action with per-action versioning @@ -10,116 +16,116 @@ import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams, // Module-level because it is passed to IAuditActionService type outside the class scope const fieldsSchemaV1 = z.object({ - seatReferenceUid: z.string(), - attendeeEmail: z.string(), - startTime: NumberChangeSchema, - endTime: NumberChangeSchema, - rescheduledToBookingUid: StringChangeSchema, + seatReferenceUid: z.string(), + attendeeEmail: z.string(), + startTime: NumberChangeSchema, + endTime: NumberChangeSchema, + 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, + 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, }); - 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); + } - getVersionedData(fields: unknown) { - return this.helper.getVersionedData(fields); - } + parseStored(data: unknown) { + return this.helper.parseStored(data); + } - parseStored(data: unknown) { - return this.helper.parseStored(data); - } + getVersion(data: unknown): number { + return this.helper.getVersion(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 }; + } - migrateToLatest(data: unknown) { - // V1-only: validate and return as-is (no migration needed) - const validated = fieldsSchemaV1.parse(data); - return { isMigrated: false, latestData: validated }; - } + getDataRequirements(): DataRequirements { + return { userUuids: [] }; + } - async getDisplayTitle({ - storedData, - userTimeZone, - }: GetDisplayTitleParams): Promise { - const { fields } = this.parseStored(storedData); - const rescheduledToBookingUid = fields.rescheduledToBookingUid.new; + 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) - : ""; + // 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, - }; - } + 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); + 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, - }; - } + 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; + 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/actions/__tests__/AcceptedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/AcceptedAuditActionService.test.ts new file mode 100644 index 0000000000..b7dc53c6b1 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/AcceptedAuditActionService.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { AcceptedAuditActionService } from "../AcceptedAuditActionService"; + +describe("AcceptedAuditActionService - getDataRequirements contract", () => { + let service: AcceptedAuditActionService; + + beforeEach(() => { + service = new AcceptedAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: {}, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/AttendeeAddedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/AttendeeAddedAuditActionService.test.ts new file mode 100644 index 0000000000..87b4782700 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/AttendeeAddedAuditActionService.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { AttendeeAddedAuditActionService } from "../AttendeeAddedAuditActionService"; + +describe("AttendeeAddedAuditActionService - getDataRequirements contract", () => { + let service: AttendeeAddedAuditActionService; + + beforeEach(() => { + service = new AttendeeAddedAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + attendeeEmails: ["new-attendee@example.com"], + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/AttendeeRemovedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/AttendeeRemovedAuditActionService.test.ts new file mode 100644 index 0000000000..184febc2a7 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/AttendeeRemovedAuditActionService.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { AttendeeRemovedAuditActionService } from "../AttendeeRemovedAuditActionService"; + +describe("AttendeeRemovedAuditActionService - getDataRequirements contract", () => { + let service: AttendeeRemovedAuditActionService; + + beforeEach(() => { + service = new AttendeeRemovedAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + attendeeEmails: ["removed-attendee@example.com"], + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/CancelledAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/CancelledAuditActionService.test.ts new file mode 100644 index 0000000000..cfbb446418 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/CancelledAuditActionService.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { CancelledAuditActionService } from "../CancelledAuditActionService"; + +describe("CancelledAuditActionService - getDataRequirements contract", () => { + let service: CancelledAuditActionService; + + beforeEach(() => { + service = new CancelledAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + cancellationReason: "User requested cancellation", + cancelledBy: "user", + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/CreatedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/CreatedAuditActionService.test.ts new file mode 100644 index 0000000000..9ffbc43370 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/CreatedAuditActionService.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { CreatedAuditActionService } from "../CreatedAuditActionService"; + +describe("CreatedAuditActionService - getDataRequirements contract", () => { + let service: CreatedAuditActionService; + + beforeEach(() => { + service = new CreatedAuditActionService(); + }); + + it("should declare exactly the userUuids accessed when hostUserUuid is present", async () => { + const storedData = { + version: 1, + fields: { + startTime: Date.now(), + endTime: Date.now() + 3600000, + status: "ACCEPTED", + hostUserUuid: "host-uuid-123", + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(1); + }); + + it("should declare empty userUuids when hostUserUuid is null", async () => { + const storedData = { + version: 1, + fields: { + startTime: Date.now(), + endTime: Date.now() + 3600000, + status: "ACCEPTED", + hostUserUuid: null, + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); + + it("should declare exactly the userUuids accessed for seated booking", async () => { + const storedData = { + version: 1, + fields: { + startTime: Date.now(), + endTime: Date.now() + 3600000, + status: "ACCEPTED", + hostUserUuid: "host-uuid-456", + seatReferenceUid: "seat-ref-123", + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(1); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/LocationChangedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/LocationChangedAuditActionService.test.ts new file mode 100644 index 0000000000..0f06a8101d --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/LocationChangedAuditActionService.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { LocationChangedAuditActionService } from "../LocationChangedAuditActionService"; + +describe("LocationChangedAuditActionService - getDataRequirements contract", () => { + let service: LocationChangedAuditActionService; + + beforeEach(() => { + service = new LocationChangedAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + location: { old: "Office A", new: "Office B" }, + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/NoShowUpdatedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/NoShowUpdatedAuditActionService.test.ts new file mode 100644 index 0000000000..6c1f0010be --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/NoShowUpdatedAuditActionService.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { NoShowUpdatedAuditActionService } from "../NoShowUpdatedAuditActionService"; + +describe("NoShowUpdatedAuditActionService - getDataRequirements contract", () => { + let service: NoShowUpdatedAuditActionService; + + beforeEach(() => { + service = new NoShowUpdatedAuditActionService(); + }); + + it("should declare exactly the userUuids accessed when host is set", async () => { + const storedData = { + version: 1, + fields: { + host: { + userUuid: "host-uuid-789", + noShow: { old: false, new: true }, + }, + }, + }; + + const { errors, accessedData} = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(1) + }); + + it("should declare empty userUuids when only attendees are set", async () => { + const storedData = { + version: 1, + fields: { + attendeesNoShow: [{ attendeeEmail: "attendee@example.com", noShow: { old: false, new: true } }], + }, + }; + + const { errors, accessedData} = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0) + }); + + it("should declare exactly the userUuids accessed when both host and attendees are set", async () => { + const storedData = { + version: 1, + fields: { + host: { + userUuid: "host-uuid-abc", + noShow: { old: null, new: true }, + }, + attendeesNoShow: [{ attendeeEmail: "attendee@example.com", noShow: { old: false, new: true } }], + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(1); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts index 1366f91fb3..907c5fbfbd 100644 --- a/packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts +++ b/packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts @@ -1,18 +1,13 @@ -import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; + import { ReassignmentAuditActionService } from "../ReassignmentAuditActionService"; +import { createMockEnrichmentDataStore, verifyDataRequirementsContract } from "./contractVerification"; describe("ReassignmentAuditActionService", () => { let service: ReassignmentAuditActionService; - let mockUserRepository: { - findByUuid: ReturnType; - }; beforeEach(() => { - mockUserRepository = { - findByUuid: vi.fn(), - }; - service = new ReassignmentAuditActionService(mockUserRepository as unknown as UserRepository); + service = new ReassignmentAuditActionService(); }); describe("getVersionedData", () => { @@ -131,6 +126,44 @@ describe("ReassignmentAuditActionService", () => { }); }); + describe("getDataRequirements", () => { + it("should return userUuids for organizer change", () => { + const storedData = { + version: 1, + fields: { + organizerUuid: { old: "organizer-old", new: "organizer-new" }, + reassignmentReason: "Host unavailable", + reassignmentType: "manual", + }, + }; + + const result = service.getDataRequirements(storedData); + + expect(result.userUuids).toContain("organizer-new"); + expect(result.userUuids).toContain("organizer-old"); + }); + + it("should return userUuids for attendee update", () => { + const storedData = { + version: 1, + fields: { + organizerUuid: { old: "fixed-host", new: "fixed-host" }, + hostAttendeeUpdated: { + id: 123, + withUserUuid: { old: "old-rr-host", new: "new-rr-host" }, + }, + reassignmentReason: null, + reassignmentType: "roundRobin", + }, + }; + + const result = service.getDataRequirements(storedData); + + expect(result.userUuids).toContain("new-rr-host"); + expect(result.userUuids).toContain("old-rr-host"); + }); + }); + describe("getDisplayTitle", () => { it("should return translation key with host name for manual reassignment (organizer changed)", async () => { const storedData = { @@ -142,11 +175,18 @@ describe("ReassignmentAuditActionService", () => { }, }; - mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-new", name: "New Host" }); + const dbStore = createMockEnrichmentDataStore( + { + users: [ + { id: 1, uuid: "organizer-new", name: "New Host", email: "new@example.com", avatarUrl: null }, + { id: 2, uuid: "organizer-old", name: "Old Host", email: "old@example.com", avatarUrl: null }, + ], + }, + { userUuids: ["organizer-new", "organizer-old"] } + ); - const result = await service.getDisplayTitle({ storedData }); + const result = await service.getDisplayTitle({ storedData, dbStore, userTimeZone: "UTC" }); - expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "organizer-new" }); expect(result).toEqual({ key: "booking_audit_action.booking_reassigned_to_host", params: { host: "New Host" }, @@ -167,11 +207,18 @@ describe("ReassignmentAuditActionService", () => { }, }; - mockUserRepository.findByUuid.mockResolvedValue({ uuid: "new-rr-host", name: "New RR Host" }); + const dbStore = createMockEnrichmentDataStore( + { + users: [ + { id: 1, uuid: "new-rr-host", name: "New RR Host", email: "newrr@example.com", avatarUrl: null }, + { id: 2, uuid: "old-rr-host", name: "Old RR Host", email: "oldrr@example.com", avatarUrl: null }, + ], + }, + { userUuids: ["new-rr-host", "old-rr-host"] } + ); - const result = await service.getDisplayTitle({ storedData }); + const result = await service.getDisplayTitle({ storedData, dbStore, userTimeZone: "UTC" }); - expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "new-rr-host" }); expect(result).toEqual({ key: "booking_audit_action.booking_reassigned_to_host", params: { host: "New RR Host" }, @@ -188,9 +235,12 @@ describe("ReassignmentAuditActionService", () => { }, }; - mockUserRepository.findByUuid.mockResolvedValue(null); + const dbStore = createMockEnrichmentDataStore( + { users: [] }, + { userUuids: ["organizer-new", "organizer-old"] } + ); - const result = await service.getDisplayTitle({ storedData }); + const result = await service.getDisplayTitle({ storedData, dbStore, userTimeZone: "UTC" }); expect(result).toEqual({ key: "booking_audit_action.booking_reassigned_to_host", @@ -208,9 +258,17 @@ describe("ReassignmentAuditActionService", () => { }, }; - mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-new", name: null }); + const dbStore = createMockEnrichmentDataStore( + { + users: [ + { id: 1, uuid: "organizer-new", name: null, email: "new@example.com", avatarUrl: null }, + { id: 2, uuid: "organizer-old", name: "Old Host", email: "old@example.com", avatarUrl: null }, + ], + }, + { userUuids: ["organizer-new", "organizer-old"] } + ); - const result = await service.getDisplayTitle({ storedData }); + const result = await service.getDisplayTitle({ storedData, dbStore, userTimeZone: "UTC" }); expect(result).toEqual({ key: "booking_audit_action.booking_reassigned_to_host", @@ -277,9 +335,17 @@ describe("ReassignmentAuditActionService", () => { }, }; - mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" }); + const dbStore = createMockEnrichmentDataStore( + { + users: [ + { id: 1, uuid: "organizer-old", name: "Previous Host", email: "old@example.com", avatarUrl: null }, + { id: 2, uuid: "organizer-new", name: "New Host", email: "new@example.com", avatarUrl: null }, + ], + }, + { userUuids: ["organizer-old", "organizer-new"] } + ); - const result = await service.getDisplayFields({ storedData }); + const result = await service.getDisplayFields({ storedData, dbStore }); expect(result).toEqual([ { @@ -303,9 +369,17 @@ describe("ReassignmentAuditActionService", () => { }, }; - mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" }); + const dbStore = createMockEnrichmentDataStore( + { + users: [ + { id: 1, uuid: "organizer-old", name: "Previous Host", email: "old@example.com", avatarUrl: null }, + { id: 2, uuid: "organizer-new", name: "New Host", email: "new@example.com", avatarUrl: null }, + ], + }, + { userUuids: ["organizer-old", "organizer-new"] } + ); - const result = await service.getDisplayFields({ storedData }); + const result = await service.getDisplayFields({ storedData, dbStore }); expect(result).toEqual([ { @@ -325,4 +399,40 @@ describe("ReassignmentAuditActionService", () => { expect(ReassignmentAuditActionService.TYPE).toBe("REASSIGNMENT"); }); }); + + describe("getDataRequirements contract verification", () => { + it("should declare exactly the userUuids accessed for organizer change", async () => { + const storedData = { + version: 1, + fields: { + organizerUuid: { old: "organizer-old", new: "organizer-new" }, + reassignmentReason: "Host unavailable", + reassignmentType: "manual", + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(2); + }); + + it("should declare exactly the userUuids accessed for attendee update", async () => { + const storedData = { + version: 1, + fields: { + organizerUuid: { old: "fixed-host", new: "fixed-host" }, + hostAttendeeUpdated: { + id: 123, + withUserUuid: { old: "old-rr-host", new: "new-rr-host" }, + }, + reassignmentReason: null, + reassignmentType: "roundRobin", + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(2); + }); + }); }); diff --git a/packages/features/booking-audit/lib/actions/__tests__/RejectedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/RejectedAuditActionService.test.ts new file mode 100644 index 0000000000..5f9658a395 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/RejectedAuditActionService.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { RejectedAuditActionService } from "../RejectedAuditActionService"; + +describe("RejectedAuditActionService - getDataRequirements contract", () => { + let service: RejectedAuditActionService; + + beforeEach(() => { + service = new RejectedAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + rejectionReason: "Schedule conflict", + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/RescheduleRequestedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/RescheduleRequestedAuditActionService.test.ts new file mode 100644 index 0000000000..7b580234f6 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/RescheduleRequestedAuditActionService.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { RescheduleRequestedAuditActionService } from "../RescheduleRequestedAuditActionService"; + +describe("RescheduleRequestedAuditActionService - getDataRequirements contract", () => { + let service: RescheduleRequestedAuditActionService; + + beforeEach(() => { + service = new RescheduleRequestedAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + rescheduleReason: "Need to change time", + rescheduledRequestedBy: "attendee", + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/RescheduledAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/RescheduledAuditActionService.test.ts new file mode 100644 index 0000000000..122420373f --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/RescheduledAuditActionService.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { RescheduledAuditActionService } from "../RescheduledAuditActionService"; + +describe("RescheduledAuditActionService - getDataRequirements contract", () => { + let service: RescheduledAuditActionService; + + beforeEach(() => { + service = new RescheduledAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + startTime: { old: Date.now(), new: Date.now() + 86400000 }, + endTime: { old: Date.now() + 3600000, new: Date.now() + 90000000 }, + rescheduledToUid: { old: null, new: "new-booking-uid" }, + rescheduledBy: "user", + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/SeatBookedAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/SeatBookedAuditActionService.test.ts new file mode 100644 index 0000000000..bda5c9cfa1 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/SeatBookedAuditActionService.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { SeatBookedAuditActionService } from "../SeatBookedAuditActionService"; + +describe("SeatBookedAuditActionService - getDataRequirements contract", () => { + let service: SeatBookedAuditActionService; + + beforeEach(() => { + service = new SeatBookedAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + seatReferenceUid: "seat-123", + attendeeEmail: "attendee@example.com", + attendeeName: "John Doe", + startTime: Date.now(), + endTime: Date.now() + 3600000, + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/SeatRescheduledAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/SeatRescheduledAuditActionService.test.ts new file mode 100644 index 0000000000..40a3994b01 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/SeatRescheduledAuditActionService.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { verifyDataRequirementsContract } from "./contractVerification"; +import { SeatRescheduledAuditActionService } from "../SeatRescheduledAuditActionService"; + +describe("SeatRescheduledAuditActionService - getDataRequirements contract", () => { + let service: SeatRescheduledAuditActionService; + + beforeEach(() => { + service = new SeatRescheduledAuditActionService(); + }); + + it("should not access any dbStore methods", async () => { + const storedData = { + version: 1, + fields: { + seatReferenceUid: "seat-456", + attendeeEmail: "attendee@example.com", + startTime: { old: Date.now(), new: Date.now() + 86400000 }, + endTime: { old: Date.now() + 3600000, new: Date.now() + 90000000 }, + rescheduledToBookingUid: { old: null, new: "new-booking-uid" }, + }, + }; + + const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData); + expect(errors).toEqual([]); + expect(accessedData.userUuids.size).toBe(0); + }); +}); diff --git a/packages/features/booking-audit/lib/actions/__tests__/contractVerification.ts b/packages/features/booking-audit/lib/actions/__tests__/contractVerification.ts new file mode 100644 index 0000000000..4bc5e17dbc --- /dev/null +++ b/packages/features/booking-audit/lib/actions/__tests__/contractVerification.ts @@ -0,0 +1,184 @@ +import type { EnrichmentDataStore, DataRequirements, StoredUser, StoredAttendee, StoredCredential } from "../../service/EnrichmentDataStore"; +import type { IAuditActionService, BaseStoredAuditData } from "../IAuditActionService"; + +type AccessedData = { + userUuids: Set; + attendeeIds: Set; + credentialIds: Set; +}; + +function createMockUser(uuid: string): StoredUser { + return { + id: Math.floor(Math.random() * 10000), + uuid, + name: `User ${uuid}`, + email: `${uuid}@example.com`, + avatarUrl: null, + }; +} + +function createMockAttendee(id: number): StoredAttendee { + return { + id, + name: `Attendee ${id}`, + email: `attendee${id}@example.com`, + }; +} + +function createMockCredential(id: number): StoredCredential { + return { + id, + appId: `app-${id}`, + }; +} + +export function createTrackingDbStore(accessedData: AccessedData): EnrichmentDataStore { + return { + getUserByUuid: (uuid: string) => { + accessedData.userUuids.add(uuid); + return createMockUser(uuid); + }, + getAttendeeById: (id: number) => { + accessedData.attendeeIds.add(id); + return createMockAttendee(id); + }, + getCredentialById: (id: number) => { + accessedData.credentialIds.add(id); + return createMockCredential(id); + }, + } as EnrichmentDataStore; +} + +export function createEmptyAccessedData(): AccessedData { + return { + userUuids: new Set(), + attendeeIds: new Set(), + credentialIds: new Set(), + }; +} + +/** + * Creates a mock EnrichmentDataStore for testing purposes. + * Pre-populates the store with the provided data and validates access against declared requirements. + */ +export function createMockEnrichmentDataStore( + data: { + users?: StoredUser[]; + attendees?: StoredAttendee[]; + credentials?: StoredCredential[]; + }, + declaredRequirements: DataRequirements +): EnrichmentDataStore { + const usersByUuid = new Map(); + const attendeesById = new Map(); + const credentialsById = new Map(); + + // Pre-populate with nulls for all declared IDs + for (const uuid of declaredRequirements.userUuids ?? []) { + usersByUuid.set(uuid, null); + } + for (const id of declaredRequirements.attendeeIds ?? []) { + attendeesById.set(id, null); + } + for (const id of declaredRequirements.credentialIds ?? []) { + credentialsById.set(id, null); + } + + // Overwrite with actual data + for (const user of data.users ?? []) { + usersByUuid.set(user.uuid, user); + } + for (const attendee of data.attendees ?? []) { + attendeesById.set(attendee.id, attendee); + } + for (const credential of data.credentials ?? []) { + credentialsById.set(credential.id, credential); + } + + return { + getUserByUuid: (uuid: string) => { + if (!usersByUuid.has(uuid)) { + throw new Error( + `EnrichmentDataStore: getUserByUuid("${uuid}") called but was not declared in getDataRequirements.` + ); + } + return usersByUuid.get(uuid) ?? null; + }, + getAttendeeById: (id: number) => { + if (!attendeesById.has(id)) { + throw new Error( + `EnrichmentDataStore: getAttendeeById(${id}) called but was not declared in getDataRequirements.` + ); + } + return attendeesById.get(id) ?? null; + }, + getCredentialById: (id: number) => { + if (!credentialsById.has(id)) { + throw new Error( + `EnrichmentDataStore: getCredentialById(${id}) called but was not declared in getDataRequirements.` + ); + } + return credentialsById.get(id) ?? null; + }, + } as EnrichmentDataStore; +} + +export async function verifyDataRequirementsContract( + service: IAuditActionService, + storedData: BaseStoredAuditData, + userTimeZone = "UTC" +): Promise<{ + declaredRequirements: DataRequirements; + accessedData: AccessedData; + errors: string[]; +}> { + const errors: string[] = []; + const accessedData = createEmptyAccessedData(); + const trackingDbStore = createTrackingDbStore(accessedData); + + const declaredRequirements = service.getDataRequirements(storedData); + + await service.getDisplayTitle({ storedData, dbStore: trackingDbStore, userTimeZone }); + + if (service.getDisplayFields) { + await service.getDisplayFields({ storedData, dbStore: trackingDbStore }); + } + + for (const uuid of accessedData.userUuids) { + if (!declaredRequirements.userUuids?.includes(uuid)) { + errors.push(`Under-declaration: getUserByUuid("${uuid}") was called but not declared in getDataRequirements`); + } + } + + for (const uuid of declaredRequirements.userUuids || []) { + if (!accessedData.userUuids.has(uuid)) { + errors.push(`Over-declaration: userUuid "${uuid}" was declared but never accessed`); + } + } + + for (const id of accessedData.attendeeIds) { + if (!declaredRequirements.attendeeIds?.includes(id)) { + errors.push(`Under-declaration: getAttendeeById(${id}) was called but not declared in getDataRequirements`); + } + } + + for (const id of declaredRequirements.attendeeIds || []) { + if (!accessedData.attendeeIds.has(id)) { + errors.push(`Over-declaration: attendeeId ${id} was declared but never accessed`); + } + } + + for (const id of accessedData.credentialIds) { + if (!declaredRequirements.credentialIds?.includes(id)) { + errors.push(`Under-declaration: getCredentialById(${id}) was called but not declared in getDataRequirements`); + } + } + + for (const id of declaredRequirements.credentialIds || []) { + if (!accessedData.credentialIds.has(id)) { + errors.push(`Over-declaration: credentialId ${id} was declared but never accessed`); + } + } + + return { declaredRequirements, accessedData, errors }; +} diff --git a/packages/features/booking-audit/lib/service/ActorStrategies.ts b/packages/features/booking-audit/lib/service/ActorStrategies.ts new file mode 100644 index 0000000000..c2b7a00092 --- /dev/null +++ b/packages/features/booking-audit/lib/service/ActorStrategies.ts @@ -0,0 +1,95 @@ +import type { AuditActorType } from "../repository/IAuditActorRepository"; +import type { BookingAuditWithActor } from "../repository/IBookingAuditRepository"; +import { getAppNameFromSlug } from "../getAppNameFromSlug"; +import type { DataRequirements, EnrichmentDataStore } from "./EnrichmentDataStore"; + +type ActorEnrichmentResult = { + displayName: string; + displayEmail: string | null; + displayAvatar: string | null; +}; + +type Actor = BookingAuditWithActor["actor"]; + +type ActorStrategy = { + getRequirements: (actor: Actor) => DataRequirements; + enrich: (actor: Actor, dbStore: EnrichmentDataStore) => ActorEnrichmentResult; +}; + +export const ACTOR_STRATEGIES: Record = { + USER: { + getRequirements: (actor) => ({ userUuids: actor.userUuid ? [actor.userUuid] : [] }), + enrich: (actor, dbStore) => { + if (!actor.userUuid) { + throw new Error("User UUID is required for USER actor"); + } + const user = dbStore.getUserByUuid(actor.userUuid); + if (user) { + return { + displayName: user.name || user.email, + displayEmail: user.email, + displayAvatar: user.avatarUrl || null, + }; + } + return { + displayName: "Deleted User", + displayEmail: null, + displayAvatar: null, + }; + }, + }, + ATTENDEE: { + getRequirements: (actor) => ({ attendeeIds: actor.attendeeId ? [actor.attendeeId] : [] }), + enrich: (actor, dbStore) => { + if (!actor.attendeeId) { + throw new Error("Attendee ID is required for ATTENDEE actor"); + } + const attendee = dbStore.getAttendeeById(actor.attendeeId); + if (attendee) { + return { + displayName: attendee.name || attendee.email, + displayEmail: attendee.email, + displayAvatar: null, + }; + } + return { + displayName: "Deleted Attendee", + displayEmail: null, + displayAvatar: null, + }; + }, + }, + APP: { + getRequirements: (actor) => ({ credentialIds: actor.credentialId ? [actor.credentialId] : [] }), + enrich: (actor, dbStore) => { + const credential = actor.credentialId ? dbStore.getCredentialById(actor.credentialId) : null; + return { + displayName: credential ? getAppNameFromSlug({ appSlug: credential.appId }) : (actor.name ?? "Deleted App"), + displayEmail: null, + displayAvatar: null, + }; + }, + }, + SYSTEM: { + getRequirements: () => ({}), + enrich: () => ({ displayName: "Cal.com", displayEmail: null, displayAvatar: null }), + }, + GUEST: { + getRequirements: () => ({}), + enrich: (actor) => ({ displayName: actor.name || "Guest", displayEmail: null, displayAvatar: null }), + }, +}; + +/** + * Get data requirements for actor enrichment using the strategy pattern + */ +export function getActorDataRequirements(actor: Actor): DataRequirements { + return ACTOR_STRATEGIES[actor.type].getRequirements(actor); +} + +/** + * Enrich actor information using the strategy pattern + */ +export function enrichActor(actor: Actor, dbStore: EnrichmentDataStore): ActorEnrichmentResult { + return ACTOR_STRATEGIES[actor.type].enrich(actor, dbStore); +} diff --git a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts index d18d64875b..0048438f52 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts @@ -1,5 +1,3 @@ -import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; -import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import type { IAuditActionService } from "../actions/IAuditActionService"; import type { BookingAuditAction } from "../repository/IBookingAuditRepository"; @@ -43,26 +41,21 @@ export type AuditActionData = * Provides a single source of truth for action service mapping and eliminates * code duplication between consumer and viewer services. */ -interface BookingAuditActionServiceRegistryDeps { - attendeeRepository: IAttendeeRepository; - userRepository: UserRepository; -} - export class BookingAuditActionServiceRegistry { private readonly actionServices: Map; - constructor(private deps: BookingAuditActionServiceRegistryDeps) { + constructor() { const services: Array<[BookingAuditAction, IAuditActionService]> = [ - ["CREATED", new CreatedAuditActionService(deps)], + ["CREATED", new CreatedAuditActionService()], ["CANCELLED", new CancelledAuditActionService()], ["RESCHEDULED", new RescheduledAuditActionService()], ["ACCEPTED", new AcceptedAuditActionService()], ["RESCHEDULE_REQUESTED", new RescheduleRequestedAuditActionService()], ["ATTENDEE_ADDED", new AttendeeAddedAuditActionService()], - ["NO_SHOW_UPDATED", new NoShowUpdatedAuditActionService({ attendeeRepository: deps.attendeeRepository, userRepository: deps.userRepository })], + ["NO_SHOW_UPDATED", new NoShowUpdatedAuditActionService()], ["REJECTED", new RejectedAuditActionService()], ["ATTENDEE_REMOVED", new AttendeeRemovedAuditActionService()], - ["REASSIGNMENT", new ReassignmentAuditActionService(deps.userRepository)], + ["REASSIGNMENT", new ReassignmentAuditActionService()], ["LOCATION_CHANGED", new LocationChangedAuditActionService()], ["SEAT_BOOKED", new SeatBookedAuditActionService()], ["SEAT_RESCHEDULED", new SeatRescheduledAuditActionService()], diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts index 4ff51ebc6b..213a54f9b4 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts @@ -1,63 +1,66 @@ -import type { JsonValue } from "@calcom/types/Json"; - -import logger from "@calcom/lib/logger"; import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface"; +import { Task } from "@calcom/features/tasker/repository"; import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; - -import type { PiiFreeActor, BookingAuditContext } from "../dto/types"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import type { JsonValue } from "@calcom/types/Json"; +import type { BookingAuditContext, PiiFreeActor } from "../dto/types"; +import type { IAuditActorRepository } from "../repository/IAuditActorRepository"; import type { - SingleBookingAuditTaskConsumerPayload, - BulkBookingAuditTaskConsumerPayload, - BookingAuditTaskConsumerPayload + BookingAuditAction, + BookingAuditCreateInput, + BookingAuditType, + IBookingAuditRepository, +} from "../repository/IBookingAuditRepository"; +import type { ActionSource } from "../types/actionSource"; +import type { + BookingAuditTaskConsumerPayload, + BulkBookingAuditTaskConsumerPayload, + SingleBookingAuditTaskConsumerPayload, } 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; - auditActorRepository: IAuditActorRepository; - featuresRepository: IFeaturesRepository; - attendeeRepository: IAttendeeRepository; - userRepository: UserRepository; + bookingAuditRepository: IBookingAuditRepository; + auditActorRepository: IAuditActorRepository; + featuresRepository: IFeaturesRepository; + attendeeRepository: IAttendeeRepository; + userRepository: UserRepository; } type CreateBookingAuditInput = { - bookingUid: string; - actorId: string; - type: BookingAuditType; - action: BookingAuditAction; - source: ActionSource; - operationId: string; - data: JsonValue; - timestamp: Date; // Required: actual time of the booking change (business event) - context?: BookingAuditContext; + bookingUid: string; + actorId: string; + type: BookingAuditType; + action: BookingAuditAction; + source: ActionSource; + operationId: string; + data: JsonValue; + timestamp: Date; // Required: actual time of the booking change (business event) + context?: BookingAuditContext; }; type BookingAudit = { - id: string; - bookingUid: string; - actorId: string; - type: BookingAuditType; - action: BookingAuditAction; - timestamp: Date; - createdAt: Date; - updatedAt: Date; - data: JsonValue; + id: string; + bookingUid: string; + actorId: string; + type: BookingAuditType; + action: BookingAuditAction; + timestamp: Date; + createdAt: Date; + updatedAt: Date; + data: JsonValue; }; /** * BookingAuditTaskConsumer - Task consumer for processing booking audit tasks * Handles all audit processing logic including feature flag checks and routing to action handlers * Designed to be deployed separately (e.g., as trigger.dev job) with minimal dependencies - * + * * Note: PENDING and AWAITING_HOST actions are intentionally not implemented. * These represent initial booking states captured by the CREATED action. - * + * * Dependency Injection Note: * - `userRepository` is included in this service's dependencies because it's required by * ActionServices (e.g., ReassignmentAuditActionService) that need to fetch user data. @@ -67,389 +70,396 @@ type BookingAudit = { * pass dependencies through intermediate layers. */ export class BookingAuditTaskConsumer { - private readonly actionServiceRegistry: BookingAuditActionServiceRegistry; - private readonly bookingAuditRepository: IBookingAuditRepository; - private readonly auditActorRepository: IAuditActorRepository; - private readonly featuresRepository: IFeaturesRepository; - private readonly attendeeRepository: IAttendeeRepository; - private readonly userRepository: UserRepository; + private readonly actionServiceRegistry: BookingAuditActionServiceRegistry; + private readonly bookingAuditRepository: IBookingAuditRepository; + private readonly auditActorRepository: IAuditActorRepository; + private readonly featuresRepository: IFeaturesRepository; - constructor(private readonly deps: BookingAuditTaskConsumerDeps) { - this.bookingAuditRepository = deps.bookingAuditRepository; - this.auditActorRepository = deps.auditActorRepository; - this.featuresRepository = deps.featuresRepository; - this.attendeeRepository = deps.attendeeRepository; - this.userRepository = deps.userRepository; + constructor(deps: BookingAuditTaskConsumerDeps) { + this.bookingAuditRepository = deps.bookingAuditRepository; + this.auditActorRepository = deps.auditActorRepository; + this.featuresRepository = deps.featuresRepository; - // Centralized registry for all action services - this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ - attendeeRepository: this.attendeeRepository, - userRepository: this.userRepository, - }); + this.actionServiceRegistry = new BookingAuditActionServiceRegistry(); + } + + /** + * Process single booking Audit Task + */ + async processAuditTask(payload: SingleBookingAuditTaskConsumerPayload, taskId: string): Promise { + const { action, bookingUid, actor, organizationId, data, timestamp, source, operationId, context } = + payload; + + if ( + !(await this.shouldProcessAudit({ + organizationId, + action, + bookingUids: [bookingUid], + })) + ) { + return; } - /** - * Process single booking Audit Task - */ - async processAuditTask(payload: SingleBookingAuditTaskConsumerPayload, taskId: string): Promise { - const { action, bookingUid, actor, organizationId, data, timestamp, source, operationId, context } = payload; + const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload, taskId }); - if (!await this.shouldProcessAudit({ - organizationId, - action, - bookingUids: [bookingUid], - })) { - return; - } + await this.onBookingAction({ + bookingUid, + actor, + action, + source, + operationId, + data: dataInLatestFormat, + timestamp, + context, + }); + } - const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload, taskId }); + /** + * Process Bulk bookings Audit Task + */ + async processBulkAuditTask(payload: BulkBookingAuditTaskConsumerPayload, taskId: string): Promise { + const { bookings, action, actor, organizationId, timestamp, source, operationId, context } = payload; - await this.onBookingAction({ bookingUid, actor, action, source, operationId, data: dataInLatestFormat, timestamp, context }); + if ( + !(await this.shouldProcessAudit({ + organizationId, + action, + bookingUids: bookings.map((booking) => booking.bookingUid), + })) + ) { + return; } - /** - * Process Bulk bookings Audit Task - */ - async processBulkAuditTask(payload: BulkBookingAuditTaskConsumerPayload, taskId: string): Promise { - const { bookings, action, actor, organizationId, timestamp, source, operationId, context } = payload; + const migratedBookings = await this.bulkMigrateIfNeeded({ + bookings, + action, + payload, + taskId, + }); - if (!await this.shouldProcessAudit({ - organizationId, - action, - bookingUids: bookings.map((booking) => booking.bookingUid), - })) { - return; - } + await this.onBulkBookingActions({ + bookings: migratedBookings, + actor, + action, + source, + operationId, + timestamp, + context, + }); + } - const migratedBookings = await this.bulkMigrateIfNeeded({ - bookings, - action, - payload, - taskId, - }); + /** + * Migrate If Needed - Validates and migrates data to latest version + * + * This is where action-specific data validation happens. + * The action service's migrateToLatest method: + * - Validates the data against all supported versions + * - Migrates to latest version if needed + * - Returns validated data with migration status + * + * If migration occurred, updates the task payload in DB for retries. + */ + private async migrateIfNeeded(params: { + action: BookingAuditAction; + data: unknown; + payload: SingleBookingAuditTaskConsumerPayload; + taskId: string; + }) { + const { action, data, payload, taskId } = params; + const actionService = this.actionServiceRegistry.getActionService(action); - await this.onBulkBookingActions({ - bookings: migratedBookings, - actor, - action, - source, - operationId, - timestamp, - context, - }); + // migrateToLatest validates data with action-specific schema and migrates if needed + const migrationResult = actionService.migrateToLatest(data); + + // If migrated, update task payload in DB + if (migrationResult.isMigrated) { + logger.info(`Schema migration performed: action=${action}`); + await this.updateTaskPayload(payload, migrationResult.latestData, taskId); } - /** - * Migrate If Needed - Validates and migrates data to latest version - * - * This is where action-specific data validation happens. - * The action service's migrateToLatest method: - * - Validates the data against all supported versions - * - Migrates to latest version if needed - * - Returns validated data with migration status - * - * If migration occurred, updates the task payload in DB for retries. - */ - private async migrateIfNeeded(params: { - action: BookingAuditAction; - data: unknown; - payload: SingleBookingAuditTaskConsumerPayload; - taskId: string; - }) { - const { action, data, payload, taskId } = params; - const actionService = this.actionServiceRegistry.getActionService(action); + return migrationResult.latestData; + } - // migrateToLatest validates data with action-specific schema and migrates if needed - const migrationResult = actionService.migrateToLatest(data); + /** + * Update Task Payload - Updates the task payload in DB with migrated data + * + * This ensures that task retries use the latest schema version. + * When a task fails and retries, it will use the already-migrated payload. + * + * @param payload - Original task payload + * @param latestData - Migrated data in latest schema version + * @param taskId - Task ID (required for DB update) + */ + private async updateTaskPayload( + payload: BookingAuditTaskConsumerPayload, + latestData: unknown, + taskId: string + ): Promise { + try { + const updatedPayload = { ...payload, data: latestData }; + await Task.updatePayload(taskId, JSON.stringify(updatedPayload)); - // If migrated, update task payload in DB + logger.info(`Successfully updated 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 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) { - logger.info( - `Schema migration performed: action=${action}` - ); - await this.updateTaskPayload(payload, migrationResult.latestData, taskId); + anyMigrated = true; } + return { + bookingUid: booking.bookingUid, + data: migrationResult.latestData, + }; + }) + ); - return migrationResult.latestData; + if (anyMigrated) { + logger.info(`Schema migration performed for bulk audit: action=${action}`); + await this.updateBulkTaskPayload(payload, migratedBookings, taskId); } - /** - * Update Task Payload - Updates the task payload in DB with migrated data - * - * This ensures that task retries use the latest schema version. - * When a task fails and retries, it will use the already-migrated payload. - * - * @param payload - Original task payload - * @param latestData - Migrated data in latest schema version - * @param taskId - Task ID (required for DB update) - */ - private async updateTaskPayload( - payload: BookingAuditTaskConsumerPayload, - latestData: unknown, - taskId: string - ): Promise { - try { - const updatedPayload = { ...payload, data: latestData }; - await Task.updatePayload(taskId, JSON.stringify(updatedPayload)); + return migratedBookings; + } - logger.info( - `Successfully updated 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 task payload in DB: taskId=${taskId}, error=${safeStringify(error)}` - ); - } + 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.debug( + `Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}` + ); + return false; } - /** - * 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); + const isFeatureEnabled = await this.featuresRepository.checkIfTeamHasFeature( + organizationId, + "booking-audit" + ); - 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; + if (!isFeatureEnabled) { + logger.debug( + `booking-audit feature is disabled for organization: action=${action}, bookingUids=${bookingUids.join(",")}, organizationId=${organizationId}` + ); + return false; } - 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)); + return true; + } - 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.debug(`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.debug( - `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: PiiFreeActor): Promise { - switch (actor.identifiedBy) { - case "id": - return actor.id; - case "user": { - const userActor = await this.auditActorRepository.createIfNotExistsUserActor({ userUuid: actor.userUuid }); - return userActor.id; - } - case "attendee": { - const attendeeActor = await this.auditActorRepository.createIfNotExistsAttendeeActor({ attendeeId: actor.attendeeId }); - return attendeeActor.id; - } - } - } - - /** - * Creates a booking audit record - * Action services handle their own version wrapping - */ - private async createAuditRecord(input: CreateBookingAuditInput): Promise { - logger.info("Creating audit record", safeStringify({ - bookingUid: input.bookingUid, - actorId: input.actorId, - type: input.type, - action: input.action, - source: input.source, - timestamp: input.timestamp, - context: input.context, - })); - - return this.bookingAuditRepository.create({ - bookingUid: input.bookingUid, - actorId: input.actorId, - type: input.type, - action: input.action, - source: input.source, - timestamp: input.timestamp, - operationId: input.operationId, - data: input.data ?? null, - context: input.context, + /** + * 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: PiiFreeActor): Promise { + switch (actor.identifiedBy) { + case "id": + return actor.id; + case "user": { + const userActor = await this.auditActorRepository.createIfNotExistsUserActor({ + userUuid: actor.userUuid, }); - } - - /** - * Get Record Type - Derives the record type from the action - * - * Maps booking actions to their corresponding audit record types: - * - CREATED → RECORD_CREATED - * - All other actions (CANCELLED, RESCHEDULED, etc.) → RECORD_UPDATED - * - Future actions like DELETED → RECORD_DELETED - * - * @param action - The booking audit action - * @returns The corresponding record type - */ - private getRecordType(params: { action: BookingAuditAction }): BookingAuditType { - const { action } = params; - - - switch (action) { - case "CREATED": - return "RECORD_CREATED"; - - // All update actions fall through to return RECORD_UPDATED - case "CANCELLED": - case "ACCEPTED": - case "REJECTED": - case "RESCHEDULED": - case "RESCHEDULE_REQUESTED": - case "ATTENDEE_ADDED": - case "ATTENDEE_REMOVED": - case "REASSIGNMENT": - case "LOCATION_CHANGED": - case "NO_SHOW_UPDATED": - case "SEAT_BOOKED": - case "SEAT_RESCHEDULED": - return "RECORD_UPDATED"; - - // PENDING and AWAITING_HOST are not implemented as they represent initial states - case "PENDING": - case "AWAITING_HOST": - throw new Error(`Action ${action} is not supported - it represents an initial booking state captured by CREATED`); - } - } - - private async onBulkBookingActions(params: { - bookings: Array<{ bookingUid: string; data: Record }>; - actor: PiiFreeActor; - action: BookingAuditAction; - source: ActionSource; - operationId: string; - timestamp: number; - context?: BookingAuditContext; - }): Promise { - const { bookings, actor, action, source, operationId, timestamp, context } = 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), - context, - }; + return userActor.id; + } + case "attendee": { + const attendeeActor = await this.auditActorRepository.createIfNotExistsAttendeeActor({ + attendeeId: actor.attendeeId, }); + return attendeeActor.id; + } + } + } - await this.bookingAuditRepository.createMany(auditRecordsToCreate); + /** + * Creates a booking audit record + * Action services handle their own version wrapping + */ + private async createAuditRecord(input: CreateBookingAuditInput): Promise { + logger.info( + "Creating audit record", + safeStringify({ + bookingUid: input.bookingUid, + actorId: input.actorId, + type: input.type, + action: input.action, + source: input.source, + timestamp: input.timestamp, + context: input.context, + }) + ); - logger.info( - `Successfully created ${auditRecordsToCreate.length} bulk audit records: action=${action}, operationId=${operationId}` + return this.bookingAuditRepository.create({ + bookingUid: input.bookingUid, + actorId: input.actorId, + type: input.type, + action: input.action, + source: input.source, + timestamp: input.timestamp, + operationId: input.operationId, + data: input.data ?? null, + context: input.context, + }); + } + + /** + * Get Record Type - Derives the record type from the action + * + * Maps booking actions to their corresponding audit record types: + * - CREATED → RECORD_CREATED + * - All other actions (CANCELLED, RESCHEDULED, etc.) → RECORD_UPDATED + * - Future actions like DELETED → RECORD_DELETED + * + * @param action - The booking audit action + * @returns The corresponding record type + */ + private getRecordType(params: { action: BookingAuditAction }): BookingAuditType { + const { action } = params; + + switch (action) { + case "CREATED": + return "RECORD_CREATED"; + + // All update actions fall through to return RECORD_UPDATED + case "CANCELLED": + case "ACCEPTED": + case "REJECTED": + case "RESCHEDULED": + case "RESCHEDULE_REQUESTED": + case "ATTENDEE_ADDED": + case "ATTENDEE_REMOVED": + case "REASSIGNMENT": + case "LOCATION_CHANGED": + case "NO_SHOW_UPDATED": + case "SEAT_BOOKED": + case "SEAT_RESCHEDULED": + return "RECORD_UPDATED"; + + // PENDING and AWAITING_HOST are not implemented as they represent initial states + case "PENDING": + case "AWAITING_HOST": + throw new Error( + `Action ${action} is not supported - it represents an initial booking state captured by CREATED` ); } + } - async onBookingAction(params: { - bookingUid: string; - actor: PiiFreeActor; - action: BookingAuditAction; - source: ActionSource; - operationId: string; - data: Record; - timestamp: number; - context?: BookingAuditContext; - }): Promise { - const { bookingUid, actor, action, source, operationId, data, timestamp, context } = params; - const actionService = this.actionServiceRegistry.getActionService(action); - const versionedData = actionService.getVersionedData(data); - const actorId = await this.resolveActorId(actor); - const recordType = this.getRecordType({ action }); + private async onBulkBookingActions(params: { + bookings: Array<{ bookingUid: string; data: Record }>; + actor: PiiFreeActor; + action: BookingAuditAction; + source: ActionSource; + operationId: string; + timestamp: number; + context?: BookingAuditContext; + }): Promise { + const { bookings, actor, action, source, operationId, timestamp, context } = params; - return this.createAuditRecord({ - bookingUid, - actorId, - type: recordType, - action, - source, - operationId, - // versionedData is { version: number; fields: unknown } which is JsonValue-compatible - data: versionedData as JsonValue, - timestamp: new Date(timestamp), - context, - }); - } + 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), + context, + }; + }); + + await this.bookingAuditRepository.createMany(auditRecordsToCreate); + + logger.info( + `Successfully created ${auditRecordsToCreate.length} bulk audit records: action=${action}, operationId=${operationId}` + ); + } + + async onBookingAction(params: { + bookingUid: string; + actor: PiiFreeActor; + action: BookingAuditAction; + source: ActionSource; + operationId: string; + data: Record; + timestamp: number; + context?: BookingAuditContext; + }): Promise { + const { bookingUid, actor, action, source, operationId, data, timestamp, context } = params; + const actionService = this.actionServiceRegistry.getActionService(action); + const versionedData = actionService.getVersionedData(data); + const actorId = await this.resolveActorId(actor); + const recordType = this.getRecordType({ action }); + + return this.createAuditRecord({ + bookingUid, + actorId, + type: recordType, + action, + source, + operationId, + // versionedData is { version: number; fields: unknown } which is JsonValue-compatible + data: versionedData as JsonValue, + timestamp: new Date(timestamp), + context, + }); + } } - diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts index a10b8045fc..4d5a5c888d 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -7,7 +7,6 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRep import type { TranslationWithParams } from "../actions/IAuditActionService"; import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService"; import type { BookingAuditContext } from "../dto/types"; -import { getAppNameFromSlug } from "../getAppNameFromSlug"; import type { AuditActorType } from "../repository/IAuditActorRepository"; import type { BookingAuditAction, @@ -16,8 +15,10 @@ import type { IBookingAuditRepository, } from "../repository/IBookingAuditRepository"; import type { ActionSource } from "../types/actionSource"; +import { enrichActor, getActorDataRequirements } from "./ActorStrategies"; import { BookingAuditAccessService } from "./BookingAuditAccessService"; import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry"; +import { type DataRequirements, EnrichmentDataStore } from "./EnrichmentDataStore"; interface BookingAuditViewerServiceDeps { bookingAuditRepository: IBookingAuditRepository; @@ -90,10 +91,7 @@ export class BookingAuditViewerService { bookingRepository: this.bookingRepository, membershipRepository: this.membershipRepository, }); - this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ - attendeeRepository: this.attendeeRepository, - userRepository: this.userRepository, - }); + this.actionServiceRegistry = new BookingAuditActionServiceRegistry(); } /** @@ -120,10 +118,19 @@ export class BookingAuditViewerService { const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid); + // Check if this booking was created from a reschedule - fetch early for data requirements + const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid); + const rescheduledLogs = fromRescheduleUid + ? await this.bookingAuditRepository.findRescheduledLogsOfBooking(fromRescheduleUid) + : []; + + const dataRequirements = this.collectDataRequirements([...auditLogs, ...rescheduledLogs]); + const dbStore = await this.buildEnrichmentDataStore(dataRequirements); + const enrichedAuditLogs = await Promise.all( auditLogs.map(async (log) => { try { - return await this.enrichAuditLog(log, userTimeZone); + return await this.enrichAuditLog(log, userTimeZone, dbStore); } catch (error) { this.log.error( `Failed to enrich audit log ${log.id}: ${error instanceof Error ? error.message : String(error)}` @@ -133,14 +140,14 @@ export class BookingAuditViewerService { }) ); - const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid); - - // Check if this booking was created from a reschedule - if (fromRescheduleUid) { + // Build rescheduled from log if applicable + if (fromRescheduleUid && rescheduledLogs.length > 0) { const rescheduledFromLog = await this.buildRescheduledFromLog({ fromRescheduleUid, currentBookingUid: bookingUid, userTimeZone, + dbStore, + rescheduledLogs, }); if (rescheduledFromLog) { enrichedAuditLogs.push(rescheduledFromLog); @@ -156,8 +163,12 @@ export class BookingAuditViewerService { /** * Enriches a single audit log with actor information and formatted display data */ - private async enrichAuditLog(log: BookingAuditWithActor, userTimeZone: string): Promise { - const enrichedActor = await this.enrichActorInformation(log.actor); + private async enrichAuditLog( + log: BookingAuditWithActor, + userTimeZone: string, + dbStore: EnrichmentDataStore + ): Promise { + const enrichedActor = enrichActor(log.actor, dbStore); const actionService = this.actionServiceRegistry.getActionService(log.action); const parsedData = actionService.parseStored(log.data); @@ -165,6 +176,7 @@ export class BookingAuditViewerService { const actionDisplayTitle = await actionService.getDisplayTitle({ storedData: parsedData, userTimeZone, + dbStore, }); const displayJson = actionService.getDisplayJson @@ -172,10 +184,10 @@ export class BookingAuditViewerService { : null; const displayFields = actionService.getDisplayFields - ? await actionService.getDisplayFields({ storedData: parsedData }) + ? await actionService.getDisplayFields({ storedData: parsedData, dbStore }) : null; - const impersonatedBy = await this.enrichImpersonator(log.context); + const impersonatedBy = this.enrichImpersonator({ context: log.context, dbStore }); return { id: log.id, @@ -249,13 +261,15 @@ export class BookingAuditViewerService { fromRescheduleUid, currentBookingUid, userTimeZone, + dbStore, + rescheduledLogs, }: { fromRescheduleUid: string; currentBookingUid: string; userTimeZone: string; + dbStore: EnrichmentDataStore; + rescheduledLogs: BookingAuditWithActor[]; }): Promise { - const rescheduledLogs = await this.bookingAuditRepository.findRescheduledLogsOfBooking(fromRescheduleUid); - // Find the specific log that created this booking by matching rescheduledToUid const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({ rescheduledLogs, @@ -268,7 +282,7 @@ export class BookingAuditViewerService { return null; } - const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone); + const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone, dbStore); const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data); // Transform the display JSON to show "rescheduled from" instead of "rescheduled to" @@ -294,16 +308,22 @@ export class BookingAuditViewerService { }; } - private async enrichImpersonator(context: BookingAuditContext | null): Promise<{ + private enrichImpersonator({ + context, + dbStore, + }: { + context: BookingAuditContext | null; + dbStore: EnrichmentDataStore; + }): { displayName: string; displayEmail: string | null; displayAvatar: string | null; - } | null> { + } | null { if (!context?.impersonatedBy) { return null; } - const impersonatorUser = await this.userRepository.findByUuid({ uuid: context.impersonatedBy }); + const impersonatorUser = dbStore.getUserByUuid(context.impersonatedBy); if (!impersonatorUser) { return { displayName: "Deleted User", @@ -319,93 +339,70 @@ export class BookingAuditViewerService { }; } + + private mergeDataRequirements(...requirements: DataRequirements[]): DataRequirements { + const userUuids = new Set(); + const attendeeIds = new Set(); + const credentialIds = new Set(); + + for (const requirement of requirements) { + for (const uuid of requirement.userUuids || []) userUuids.add(uuid); + for (const id of requirement.attendeeIds || []) attendeeIds.add(id); + for (const id of requirement.credentialIds || []) credentialIds.add(id); + } + + return { + userUuids: Array.from(userUuids), + attendeeIds: Array.from(attendeeIds), + credentialIds: Array.from(credentialIds), + }; + } + /** - * Enrich actor information with user details if userUuid exists + * Collect all data requirements from audit logs and action services */ - private async enrichActorInformation(actor: BookingAuditWithActor["actor"]): Promise<{ - displayName: string; - displayEmail: string | null; - displayAvatar: string | null; - }> { - switch (actor.type) { - case "SYSTEM": - return { - displayName: "Cal.com", - displayEmail: null, - displayAvatar: null, - }; + private collectDataRequirements(auditLogs: BookingAuditWithActor[]): DataRequirements { + let actorRequirements: DataRequirements[] = []; + let serviceRequirements: DataRequirements[] = []; + let contextRequirements: DataRequirements[] = []; + for (const log of auditLogs) { + actorRequirements.push(getActorDataRequirements(log.actor)); - 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, - }; + const context = log.context + if (context?.impersonatedBy) { + contextRequirements.push({ userUuids: [context.impersonatedBy] }); } - 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, - }; + try { + const actionService = this.actionServiceRegistry.getActionService(log.action); + const parsedData = actionService.parseStored(log.data); + serviceRequirements.push(actionService.getDataRequirements(parsedData)); + } catch(error) { + this.log.error( + `Failed to get data requirements for action ${log.action}: ${error instanceof Error ? error.message : String(error)}` + ); } } + + const allDataRequirements = this.mergeDataRequirements( + ...actorRequirements, + ...serviceRequirements, + ...contextRequirements + ); + + return allDataRequirements; + } + + /** + * Build the enrichment data store by bulk-fetching all required data + */ + private async buildEnrichmentDataStore(requirements: DataRequirements): Promise { + const dbStore = new EnrichmentDataStore(requirements, { + userRepository: this.userRepository, + attendeeRepository: this.attendeeRepository, + credentialRepository: this.credentialRepository, + }); + await dbStore.fetch(); + return dbStore; } } diff --git a/packages/features/booking-audit/lib/service/EnrichmentDataStore.ts b/packages/features/booking-audit/lib/service/EnrichmentDataStore.ts new file mode 100644 index 0000000000..82ec67801f --- /dev/null +++ b/packages/features/booking-audit/lib/service/EnrichmentDataStore.ts @@ -0,0 +1,190 @@ +import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; +import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; + +/** + * Entity types stored in the enrichment data store + * These represent the minimal data needed for audit log enrichment + */ + +export type StoredUser = { + id: number; + uuid: string; + name: string | null; + email: string; + avatarUrl: string | null; +}; + +export type StoredAttendee = { + id: number; + name: string; + email: string; +}; + +export type StoredCredential = { + id: number; + appId: string | null; +}; + +/** + * Data requirements that action services can declare + * Used to collect all identifiers needed before bulk fetching + */ +export type DataRequirements = { + userUuids?: string[]; + attendeeIds?: number[]; + credentialIds?: number[]; +}; + +/** + * Mapper functions to ensure only defined properties are stored avoiding unnecessary memory usage + * These functions extract only the properties defined in StoredUser, StoredAttendee, and StoredCredential + * Accepts broader types that extend the stored types (allowing additional properties) + */ +function mapToStoredUser(user: T): StoredUser { + return { + id: user.id, + uuid: user.uuid, + name: user.name, + email: user.email, + avatarUrl: user.avatarUrl, + }; +} + +function mapToStoredAttendee(attendee: T): StoredAttendee { + return { + id: attendee.id, + name: attendee.name, + email: attendee.email, + }; +} + +function mapToStoredCredential(credential: T): StoredCredential { + return { + id: credential.id, + appId: credential.appId, + }; +} + +/** + * Repository dependencies for fetching data + */ +export interface EnrichmentDataStoreRepositories { + userRepository: UserRepository; + attendeeRepository: IAttendeeRepository; + credentialRepository: CredentialRepository; +} + +/** + * EnrichmentDataStore + * + * Holds pre-fetched data for audit log enrichment. + * Action services use this store instead of making individual DB queries, + * eliminating N+1 query problems. + * + * The store validates that accessed data was declared in requirements - throws an error + * if code tries to access data that wasn't declared (catches bugs at runtime). + * + * Usage: + * ``` + * const store = new EnrichmentDataStore(requirements, repositories); + * await store.fetch(); + * const user = store.getUserByUuid("uuid"); // throws if "uuid" wasn't declared + * ``` + */ +export class EnrichmentDataStore { + private usersByUuid: Map = new Map(); + private attendeesById: Map = new Map(); + private credentialsById: Map = new Map(); + + constructor( + private requirements: DataRequirements, + private repositories: EnrichmentDataStoreRepositories + ) { + // Pre-populate maps with null for all declared IDs + // This marks them as "declared" - the key exists, but data not yet fetched + for (const uuid of requirements.userUuids ?? []) { + this.usersByUuid.set(uuid, null); + } + for (const id of requirements.attendeeIds ?? []) { + this.attendeesById.set(id, null); + } + for (const id of requirements.credentialIds ?? []) { + this.credentialsById.set(id, null); + } + } + + /** + * Fetch all declared data from the database + * Must be called before using any getter methods + */ + async fetch(): Promise { + const [users, attendees, credentials] = await Promise.all([ + this.requirements.userUuids?.length + ? this.repositories.userRepository.findByUuids({ uuids: this.requirements.userUuids }) + : [], + this.requirements.attendeeIds?.length + ? this.repositories.attendeeRepository.findByIds({ ids: this.requirements.attendeeIds }) + : [], + this.requirements.credentialIds?.length + ? this.repositories.credentialRepository.findByIds({ ids: this.requirements.credentialIds }) + : [], + ]); + + // Overwrite nulls with actual data from DB, using mappers to ensure only defined properties are stored + for (const user of users) { + this.usersByUuid.set(user.uuid, mapToStoredUser(user)); + } + for (const attendee of attendees) { + this.attendeesById.set(attendee.id, mapToStoredAttendee(attendee)); + } + for (const credential of credentials) { + this.credentialsById.set(credential.id, mapToStoredCredential(credential)); + } + } + + /** + * Get user by UUID + * Throws error if UUID was not declared in requirements (bug in getDataRequirements) + * Returns null if user was declared but doesn't exist in database + */ + getUserByUuid(uuid: string): StoredUser | null { + if (!this.usersByUuid.has(uuid)) { + throw new Error( + `EnrichmentDataStore: getUserByUuid("${uuid}") called but was not declared in getDataRequirements. ` + + `This is a bug - ensure the action service declares all required userUuids.` + ); + } + return this.usersByUuid.get(uuid) ?? null; + } + + /** + * Get attendee by ID + * Throws error if ID was not declared in requirements (bug in getDataRequirements) + * Returns null if attendee was declared but doesn't exist in database + */ + getAttendeeById(id: number): StoredAttendee | null { + if (!this.attendeesById.has(id)) { + throw new Error( + `EnrichmentDataStore: getAttendeeById(${id}) called but was not declared in getDataRequirements. ` + + `This is a bug - ensure the action service declares all required attendeeIds.` + ); + } + return this.attendeesById.get(id) ?? null; + } + + /** + * Get credential by ID + * Throws error if ID was not declared in requirements (bug in getDataRequirements) + * Returns null if credential was declared but doesn't exist in database + */ + getCredentialById(id: number): StoredCredential | null { + if (!this.credentialsById.has(id)) { + throw new Error( + `EnrichmentDataStore: getCredentialById(${id}) called but was not declared in getDataRequirements. ` + + `This is a bug - ensure the action service declares all required credentialIds.` + ); + } + return this.credentialsById.get(id) ?? null; + } +} diff --git a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts index af26010da8..2ecf8ac483 100644 --- a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts +++ b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts @@ -150,6 +150,7 @@ const createMockAuditLog = ( type MockUser = { id: number; + uuid: string; name: string | null; email: string; avatarUrl: string | null; @@ -157,17 +158,19 @@ type MockUser = { const createMockUser = ( uuid?: string, - overrides?: Partial<{ id: number; name: string | null; email: string; avatarUrl: string | null }> + overrides?: Partial<{ id: number; uuid: string; name: string | null; email: string; avatarUrl: string | null }> ) => { + const userUuid = uuid ?? overrides?.uuid ?? `user-uuid-${overrides?.id ?? 123}`; const user: MockUser = { id: overrides?.id ?? 123, + uuid: userUuid, 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, }; - if (uuid) { - DB.users[uuid] = user; + if (userUuid) { + DB.users[userUuid] = user; } return user; @@ -197,6 +200,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { let mockUserRepository: { getUserOrganizationAndTeams: Mock; findByUuid: Mock; + findByUuids: Mock; }; let mockBookingAuditRepository: { create: Mock; @@ -211,9 +215,11 @@ describe("BookingAuditViewerService - Integration Tests", () => { }; let mockAttendeeRepository: { findById: Mock; + findByIds: Mock; }; let mockCredentialRepository: { findByCredentialId: Mock; + findByIds: Mock; }; let mockLog: { error: Mock; @@ -242,6 +248,9 @@ describe("BookingAuditViewerService - Integration Tests", () => { findByUuid: vi.fn().mockImplementation(({ uuid }: { uuid: string }) => { return Promise.resolve(DB.users[uuid] ?? null); }), + findByUuids: vi.fn().mockImplementation(({ uuids }: { uuids: string[] }) => { + return Promise.resolve(uuids.map((uuid) => DB.users[uuid]).filter(Boolean)); + }), }; mockBookingAuditRepository = { @@ -270,10 +279,14 @@ describe("BookingAuditViewerService - Integration Tests", () => { findById: vi.fn().mockImplementation((id: number) => { return Promise.resolve(DB.attendees[id] ?? null); }), + findByIds: vi.fn().mockImplementation(({ ids }: { ids: number[] }) => { + return Promise.resolve(ids.map((id) => DB.attendees[id]).filter(Boolean)); + }), }; mockCredentialRepository = { findByCredentialId: vi.fn(), + findByIds: vi.fn().mockImplementation(() => Promise.resolve([])), }; mockLog = { @@ -475,7 +488,9 @@ describe("BookingAuditViewerService - Integration Tests", () => { organizationId: 200, }); - expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "user-uuid-456" }); + expect(mockUserRepository.findByUuids).toHaveBeenCalledWith({ + uuids: expect.arrayContaining(["user-uuid-456"]), + }); expect(result.auditLogs[0].actor).toMatchObject({ displayName: "Jane Smith", displayEmail: "jane@example.com", @@ -610,7 +625,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { organizationId: 200, }); - expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(999); + expect(mockAttendeeRepository.findByIds).toHaveBeenCalledWith({ ids: [999] }); expect(result.auditLogs[0].actor).toMatchObject({ type: "ATTENDEE", displayName: "attendee@example.com", @@ -637,7 +652,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { organizationId: 200, }); - expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(888); + expect(mockAttendeeRepository.findByIds).toHaveBeenCalledWith({ ids: [888] }); expect(result.auditLogs[0].actor.displayName).toBe("Meeting Participant"); expect(result.auditLogs[0].actor.displayEmail).toBe("participant@example.com"); }); @@ -660,7 +675,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { organizationId: 200, }); - expect(mockAttendeeRepository.findById).toHaveBeenCalledWith(777); + expect(mockAttendeeRepository.findByIds).toHaveBeenCalledWith({ ids: [777] }); expect(result.auditLogs[0].actor).toMatchObject({ type: "ATTENDEE", displayName: "Deleted Attendee", @@ -1000,7 +1015,9 @@ describe("BookingAuditViewerService - Integration Tests", () => { organizationId: 200, }); - expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "impersonator-uuid-456" }); + expect(mockUserRepository.findByUuids).toHaveBeenCalledWith({ + uuids: expect.arrayContaining(["impersonator-uuid-456"]), + }); expect(result.auditLogs[0].impersonatedBy).toMatchObject({ displayName: "Admin User", displayEmail: "admin@example.com", @@ -1051,7 +1068,9 @@ describe("BookingAuditViewerService - Integration Tests", () => { organizationId: 200, }); - expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "impersonator-uuid-456" }); + expect(mockUserRepository.findByUuids).toHaveBeenCalledWith({ + uuids: expect.arrayContaining(["impersonator-uuid-456"]), + }); expect(result.auditLogs[0].impersonatedBy).toMatchObject({ displayName: "Deleted User", displayEmail: null, diff --git a/packages/features/booking-audit/lib/service/__tests__/EnrichmentDataStore.test.ts b/packages/features/booking-audit/lib/service/__tests__/EnrichmentDataStore.test.ts new file mode 100644 index 0000000000..6d0b16f66f --- /dev/null +++ b/packages/features/booking-audit/lib/service/__tests__/EnrichmentDataStore.test.ts @@ -0,0 +1,340 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; +import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; + +import { EnrichmentDataStore } from "../EnrichmentDataStore"; + +describe("EnrichmentDataStore", () => { + const mockUserRepository = { + findByUuids: vi.fn(), + } as unknown as UserRepository; + + const mockAttendeeRepository = { + findByIds: vi.fn(), + } as unknown as IAttendeeRepository; + + const mockCredentialRepository = { + findByIds: vi.fn(), + } as unknown as CredentialRepository; + + const repositories = { + userRepository: mockUserRepository, + attendeeRepository: mockAttendeeRepository, + credentialRepository: mockCredentialRepository, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("constructor", () => { + it("should pre-populate usersByUuid map with nulls for declared userUuids", () => { + const store = new EnrichmentDataStore( + { userUuids: ["uuid-1", "uuid-2"] }, + repositories + ); + + expect(() => store.getUserByUuid("uuid-1")).not.toThrow(); + expect(() => store.getUserByUuid("uuid-2")).not.toThrow(); + expect(store.getUserByUuid("uuid-1")).toBeNull(); + expect(store.getUserByUuid("uuid-2")).toBeNull(); + }); + + it("should pre-populate attendeesById map with nulls for declared attendeeIds", () => { + const store = new EnrichmentDataStore( + { attendeeIds: [1, 2, 3] }, + repositories + ); + + expect(() => store.getAttendeeById(1)).not.toThrow(); + expect(() => store.getAttendeeById(2)).not.toThrow(); + expect(() => store.getAttendeeById(3)).not.toThrow(); + expect(store.getAttendeeById(1)).toBeNull(); + }); + + it("should pre-populate credentialsById map with nulls for declared credentialIds", () => { + const store = new EnrichmentDataStore( + { credentialIds: [10, 20] }, + repositories + ); + + expect(() => store.getCredentialById(10)).not.toThrow(); + expect(() => store.getCredentialById(20)).not.toThrow(); + expect(store.getCredentialById(10)).toBeNull(); + }); + + it("should handle empty requirements", () => { + const store = new EnrichmentDataStore({}, repositories); + + expect(() => store.getUserByUuid("any-uuid")).toThrow(); + expect(() => store.getAttendeeById(1)).toThrow(); + expect(() => store.getCredentialById(1)).toThrow(); + }); + }); + + describe("fetch", () => { + it("should fetch users from repository and populate the map", async () => { + const mockUsers = [ + { id: 1, uuid: "uuid-1", name: "User 1", email: "user1@example.com", avatarUrl: null }, + { id: 2, uuid: "uuid-2", name: "User 2", email: "user2@example.com", avatarUrl: "avatar.jpg" }, + ]; + vi.mocked(mockUserRepository.findByUuids).mockResolvedValue(mockUsers); + + const store = new EnrichmentDataStore( + { userUuids: ["uuid-1", "uuid-2"] }, + repositories + ); + await store.fetch(); + + expect(mockUserRepository.findByUuids).toHaveBeenCalledWith({ uuids: ["uuid-1", "uuid-2"] }); + expect(store.getUserByUuid("uuid-1")).toEqual(mockUsers[0]); + expect(store.getUserByUuid("uuid-2")).toEqual(mockUsers[1]); + }); + + it("should fetch attendees from repository and populate the map", async () => { + const mockAttendees = [ + { id: 1, name: "Attendee 1", email: "attendee1@example.com" }, + { id: 2, name: "Attendee 2", email: "attendee2@example.com" }, + ]; + vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue(mockAttendees); + + const store = new EnrichmentDataStore( + { attendeeIds: [1, 2] }, + repositories + ); + await store.fetch(); + + expect(mockAttendeeRepository.findByIds).toHaveBeenCalledWith({ ids: [1, 2] }); + expect(store.getAttendeeById(1)).toEqual(mockAttendees[0]); + expect(store.getAttendeeById(2)).toEqual(mockAttendees[1]); + }); + + it("should fetch credentials from repository and populate the map", async () => { + const mockCredentials = [ + { id: 10, appId: "google-calendar" }, + { id: 20, appId: "zoom" }, + ]; + vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue(mockCredentials); + + const store = new EnrichmentDataStore( + { credentialIds: [10, 20] }, + repositories + ); + await store.fetch(); + + expect(mockCredentialRepository.findByIds).toHaveBeenCalledWith({ ids: [10, 20] }); + expect(store.getCredentialById(10)).toEqual(mockCredentials[0]); + expect(store.getCredentialById(20)).toEqual(mockCredentials[1]); + }); + + it("should fetch all data types in parallel", async () => { + vi.mocked(mockUserRepository.findByUuids).mockResolvedValue([ + { id: 1, uuid: "uuid-1", name: "User", email: "user@example.com", avatarUrl: null }, + ]); + vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue([ + { id: 1, name: "Attendee", email: "attendee@example.com" }, + ]); + vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue([ + { id: 10, appId: "app" }, + ]); + + const store = new EnrichmentDataStore( + { userUuids: ["uuid-1"], attendeeIds: [1], credentialIds: [10] }, + repositories + ); + await store.fetch(); + + expect(mockUserRepository.findByUuids).toHaveBeenCalled(); + expect(mockAttendeeRepository.findByIds).toHaveBeenCalled(); + expect(mockCredentialRepository.findByIds).toHaveBeenCalled(); + }); + + it("should not call repository if no IDs are declared", async () => { + const store = new EnrichmentDataStore({}, repositories); + await store.fetch(); + + expect(mockUserRepository.findByUuids).not.toHaveBeenCalled(); + expect(mockAttendeeRepository.findByIds).not.toHaveBeenCalled(); + expect(mockCredentialRepository.findByIds).not.toHaveBeenCalled(); + }); + + it("should leave null for declared IDs that are not found in database", async () => { + vi.mocked(mockUserRepository.findByUuids).mockResolvedValue([ + { id: 1, uuid: "uuid-1", name: "User 1", email: "user1@example.com", avatarUrl: null }, + ]); + + const store = new EnrichmentDataStore( + { userUuids: ["uuid-1", "uuid-2", "uuid-3"] }, + repositories + ); + await store.fetch(); + + expect(store.getUserByUuid("uuid-1")).not.toBeNull(); + expect(store.getUserByUuid("uuid-2")).toBeNull(); + expect(store.getUserByUuid("uuid-3")).toBeNull(); + }); + }); + + describe("getUserByUuid", () => { + it("should throw error when accessing undeclared UUID", () => { + const store = new EnrichmentDataStore( + { userUuids: ["uuid-1"] }, + repositories + ); + + expect(() => store.getUserByUuid("undeclared-uuid")).toThrow( + 'EnrichmentDataStore: getUserByUuid("undeclared-uuid") called but was not declared in getDataRequirements' + ); + }); + + it("should return null for declared UUID that does not exist in database", async () => { + vi.mocked(mockUserRepository.findByUuids).mockResolvedValue([]); + + const store = new EnrichmentDataStore( + { userUuids: ["uuid-1"] }, + repositories + ); + await store.fetch(); + + expect(store.getUserByUuid("uuid-1")).toBeNull(); + }); + + it("should return user data for declared UUID that exists in database", async () => { + const mockUser = { id: 1, uuid: "uuid-1", name: "Test User", email: "test@example.com", avatarUrl: null }; + vi.mocked(mockUserRepository.findByUuids).mockResolvedValue([mockUser]); + + const store = new EnrichmentDataStore( + { userUuids: ["uuid-1"] }, + repositories + ); + await store.fetch(); + + expect(store.getUserByUuid("uuid-1")).toEqual(mockUser); + }); + }); + + describe("getAttendeeById", () => { + it("should throw error when accessing undeclared attendee ID", () => { + const store = new EnrichmentDataStore( + { attendeeIds: [1] }, + repositories + ); + + expect(() => store.getAttendeeById(999)).toThrow( + "EnrichmentDataStore: getAttendeeById(999) called but was not declared in getDataRequirements" + ); + }); + + it("should return null for declared ID that does not exist in database", async () => { + vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue([]); + + const store = new EnrichmentDataStore( + { attendeeIds: [1] }, + repositories + ); + await store.fetch(); + + expect(store.getAttendeeById(1)).toBeNull(); + }); + + it("should return attendee data for declared ID that exists in database", async () => { + const mockAttendee = { id: 1, name: "Test Attendee", email: "attendee@example.com" }; + vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue([mockAttendee]); + + const store = new EnrichmentDataStore( + { attendeeIds: [1] }, + repositories + ); + await store.fetch(); + + expect(store.getAttendeeById(1)).toEqual(mockAttendee); + }); + }); + + describe("getCredentialById", () => { + it("should throw error when accessing undeclared credential ID", () => { + const store = new EnrichmentDataStore( + { credentialIds: [10] }, + repositories + ); + + expect(() => store.getCredentialById(999)).toThrow( + "EnrichmentDataStore: getCredentialById(999) called but was not declared in getDataRequirements" + ); + }); + + it("should return null for declared ID that does not exist in database", async () => { + vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue([]); + + const store = new EnrichmentDataStore( + { credentialIds: [10] }, + repositories + ); + await store.fetch(); + + expect(store.getCredentialById(10)).toBeNull(); + }); + + it("should return credential data for declared ID that exists in database", async () => { + const mockCredential = { id: 10, appId: "google-calendar" }; + vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue([mockCredential]); + + const store = new EnrichmentDataStore( + { credentialIds: [10] }, + repositories + ); + await store.fetch(); + + expect(store.getCredentialById(10)).toEqual(mockCredential); + }); + }); + + describe("multiple IDs", () => { + it("should handle multiple user UUIDs correctly", async () => { + const mockUsers = [ + { id: 1, uuid: "uuid-1", name: "User 1", email: "user1@example.com", avatarUrl: null }, + { id: 2, uuid: "uuid-2", name: "User 2", email: "user2@example.com", avatarUrl: null }, + { id: 3, uuid: "uuid-3", name: "User 3", email: "user3@example.com", avatarUrl: null }, + ]; + vi.mocked(mockUserRepository.findByUuids).mockResolvedValue(mockUsers); + + const store = new EnrichmentDataStore( + { userUuids: ["uuid-1", "uuid-2", "uuid-3"] }, + repositories + ); + await store.fetch(); + + expect(store.getUserByUuid("uuid-1")).toEqual(mockUsers[0]); + expect(store.getUserByUuid("uuid-2")).toEqual(mockUsers[1]); + expect(store.getUserByUuid("uuid-3")).toEqual(mockUsers[2]); + }); + + it("should handle mixed found and not-found IDs", async () => { + vi.mocked(mockUserRepository.findByUuids).mockResolvedValue([ + { id: 1, uuid: "uuid-1", name: "User 1", email: "user1@example.com", avatarUrl: null }, + ]); + vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue([ + { id: 2, name: "Attendee 2", email: "attendee2@example.com" }, + ]); + vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue([]); + + const store = new EnrichmentDataStore( + { + userUuids: ["uuid-1", "uuid-missing"], + attendeeIds: [1, 2], + credentialIds: [10], + }, + repositories + ); + await store.fetch(); + + expect(store.getUserByUuid("uuid-1")).not.toBeNull(); + expect(store.getUserByUuid("uuid-missing")).toBeNull(); + expect(store.getAttendeeById(1)).toBeNull(); + expect(store.getAttendeeById(2)).not.toBeNull(); + expect(store.getCredentialById(10)).toBeNull(); + }); + }); +}); diff --git a/packages/features/credentials/repositories/CredentialRepository.ts b/packages/features/credentials/repositories/CredentialRepository.ts index 2ce797c373..04100a5fc7 100644 --- a/packages/features/credentials/repositories/CredentialRepository.ts +++ b/packages/features/credentials/repositories/CredentialRepository.ts @@ -35,6 +35,14 @@ export class CredentialRepository { }); } + async findByIds({ ids }: { ids: number[] }): Promise<{ id: number; appId: string | null }[]> { + if (ids.length === 0) return []; + return this.prismaClient.credential.findMany({ + where: { id: { in: ids } }, + select: { id: true, appId: true }, + }); + } + async findByIdWithDelegationCredential(id: number) { return this.prismaClient.credential.findUnique({ where: { id }, diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 252b93c809..c526d8b6be 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -81,6 +81,7 @@ const teamSelect = { const userSelect = { id: true, + uuid: true, username: true, name: true, email: true, @@ -426,6 +427,24 @@ export class UserRepository { }); } + async findByUuids({ uuids }: { uuids: string[] }) { + if (uuids.length === 0) return []; + return this.prismaClient.user.findMany({ + where: { + uuid: { + in: uuids, + }, + }, + select: { + id: true, + uuid: true, + name: true, + email: true, + avatarUrl: true, + }, + }); + } + async findByIdOrThrow({ id }: { id: number }) { const user = await this.findById({ id }); if (!user) {