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:
co-authored by
hariom@cal.com <hariombalhara@gmail.com>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
39135f84ef
commit
b74dd3227c
@@ -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;
|
||||
};
|
||||
|
||||
+23
@@ -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);
|
||||
});
|
||||
});
|
||||
+25
@@ -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);
|
||||
});
|
||||
});
|
||||
+25
@@ -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);
|
||||
});
|
||||
});
|
||||
+26
@@ -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);
|
||||
});
|
||||
});
|
||||
+61
@@ -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);
|
||||
});
|
||||
});
|
||||
+25
@@ -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);
|
||||
});
|
||||
});
|
||||
+58
@@ -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);
|
||||
});
|
||||
});
|
||||
+133
-23
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
@@ -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);
|
||||
});
|
||||
});
|
||||
+26
@@ -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);
|
||||
});
|
||||
});
|
||||
+28
@@ -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);
|
||||
});
|
||||
});
|
||||
+29
@@ -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);
|
||||
});
|
||||
});
|
||||
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
-9
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user