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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com>

* fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com>

* fix: Remove duplicate fireBookingEvents method in RecurringBookingService

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* revert: Remove formatting-only changes in audit action services

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com>

* fix: Restore original test assertions, only update mocks for bulk methods

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* test: Add contract verification tests for getDataRequirements

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.com>

* test: Split contract verification tests into separate files per action service

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* test: Rename contract test files to .test.ts and merge into existing test file

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: Add runtime validation to EnrichmentDataStore for undeclared data access

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Make declaredRequirements required and add ensureFetched helper

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.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 <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2026-02-06 16:08:38 +05:30
committed by GitHub
co-authored by hariom@cal.com <hariombalhara@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 39135f84ef
commit b74dd3227c
37 changed files with 2157 additions and 735 deletions
@@ -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<TranslationWithParams> {
return { key: "booking_audit_action.accepted" };
}
@@ -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<TranslationWithParams> {
return { key: "booking_audit_action.attendee_added" };
}
@@ -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<TranslationWithParams> {
return { key: "booking_audit_action.attendee_removed" };
}
@@ -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<TranslationWithParams> {
return { key: "booking_audit_action.cancelled" };
}
@@ -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<typeof CreatedAuditActionService.latestFieldsSchema, typeof CreatedAuditActionService.storedDataSchema>;
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<TranslationWithParams> {
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<TranslationWithParams> {
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 } };
@@ -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<string, unknown>;
/**
* 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
@@ -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<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
// TODO: Ideally we want to translate the location label to the user's locale
@@ -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<TranslationWithParams> {
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 });
@@ -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<TranslationWithParams> {
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<TranslationWithParams> {
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",
@@ -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<TranslationWithParams> {
return { key: "booking_audit_action.rejected" };
}
@@ -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<TranslationWithParams> {
return { key: "booking_audit_action.reschedule_requested" };
}
@@ -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,
@@ -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<typeof SeatBookedAuditActionService.latestFieldsSchema, typeof SeatBookedAuditActionService.storedDataSchema>;
}
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<TranslationWithParams> {
return { key: "booking_audit_action.seat_booked" };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
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<typeof fieldsSchemaV1>;
export type SeatBookedAuditDisplayData = {
seatReferenceUid: string;
attendeeEmail: string;
attendeeName: string;
startTime: string;
endTime: string;
seatReferenceUid: string;
attendeeEmail: string;
attendeeName: string;
startTime: string;
endTime: string;
};
@@ -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<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const rescheduledToBookingUid = fields.rescheduledToBookingUid.new;
async getDisplayTitle({ storedData, userTimeZone }: GetDisplayTitleParams): Promise<TranslationWithParams> {
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<typeof fieldsSchemaV1>;
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;
};
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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<typeof vi.fn>;
};
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);
});
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -0,0 +1,184 @@
import type { EnrichmentDataStore, DataRequirements, StoredUser, StoredAttendee, StoredCredential } from "../../service/EnrichmentDataStore";
import type { IAuditActionService, BaseStoredAuditData } from "../IAuditActionService";
type AccessedData = {
userUuids: Set<string>;
attendeeIds: Set<number>;
credentialIds: Set<number>;
};
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<string, StoredUser | null>();
const attendeesById = new Map<number, StoredAttendee | null>();
const credentialsById = new Map<number, StoredCredential | null>();
// 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 };
}
@@ -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<AuditActorType, ActorStrategy> = {
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);
}
@@ -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<BookingAuditAction, IAuditActionService>;
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()],
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Array<{ bookingUid: string; data: Record<string, unknown> }>> {
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<void> {
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<string, unknown> }>,
taskId: string
): Promise<void> {
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<boolean> {
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<Array<{ bookingUid: string; data: Record<string, unknown> }>> {
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<string, unknown> }>,
taskId: string
): Promise<void> {
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<boolean> {
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<string> {
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<BookingAudit> {
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<string> {
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<string, unknown> }>;
actor: PiiFreeActor;
action: BookingAuditAction;
source: ActionSource;
operationId: string;
timestamp: number;
context?: BookingAuditContext;
}): Promise<void> {
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<BookingAudit> {
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<string, unknown>;
timestamp: number;
context?: BookingAuditContext;
}): Promise<BookingAudit> {
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<string, unknown> }>;
actor: PiiFreeActor;
action: BookingAuditAction;
source: ActionSource;
operationId: string;
timestamp: number;
context?: BookingAuditContext;
}): Promise<void> {
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<string, unknown>;
timestamp: number;
context?: BookingAuditContext;
}): Promise<BookingAudit> {
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,
});
}
}
@@ -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<EnrichedAuditLog> {
const enrichedActor = await this.enrichActorInformation(log.actor);
private async enrichAuditLog(
log: BookingAuditWithActor,
userTimeZone: string,
dbStore: EnrichmentDataStore
): Promise<EnrichedAuditLog> {
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<EnrichedAuditLog | null> {
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<string>();
const attendeeIds = new Set<number>();
const credentialIds = new Set<number>();
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<EnrichmentDataStore> {
const dbStore = new EnrichmentDataStore(requirements, {
userRepository: this.userRepository,
attendeeRepository: this.attendeeRepository,
credentialRepository: this.credentialRepository,
});
await dbStore.fetch();
return dbStore;
}
}
@@ -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<T extends StoredUser>(user: T): StoredUser {
return {
id: user.id,
uuid: user.uuid,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
};
}
function mapToStoredAttendee<T extends StoredAttendee>(attendee: T): StoredAttendee {
return {
id: attendee.id,
name: attendee.name,
email: attendee.email,
};
}
function mapToStoredCredential<T extends StoredCredential>(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<string, StoredUser | null> = new Map();
private attendeesById: Map<number, StoredAttendee | null> = new Map();
private credentialsById: Map<number, StoredCredential | null> = 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<void> {
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;
}
}
@@ -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<UserRepository["getUserOrganizationAndTeams"]>;
findByUuid: Mock<UserRepository["findByUuid"]>;
findByUuids: Mock<UserRepository["findByUuids"]>;
};
let mockBookingAuditRepository: {
create: Mock<IBookingAuditRepository["create"]>;
@@ -211,9 +215,11 @@ describe("BookingAuditViewerService - Integration Tests", () => {
};
let mockAttendeeRepository: {
findById: Mock;
findByIds: Mock;
};
let mockCredentialRepository: {
findByCredentialId: Mock<CredentialRepository["findByCredentialId"]>;
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,
@@ -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();
});
});
});
@@ -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 },
@@ -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) {