* 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: Replace hardcoded color with semantic text-success class Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Remove color class completely from display fields Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * feat: Add valuesWithParams support for translatable complex field values Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor(booking-audit): use discriminated union for displayFields and update consumers Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: add explicit return type to getBookingHistoryHandler to bust stale tRPC build cache Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: replace $t() nested interpolation with separate translation keys and add translationsWithParams tests Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
|
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
|
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
|
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
|
|
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
|
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
import type { DisplayField, TranslationWithParams } from "../actions/IAuditActionService";
|
|
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
|
|
import type { BookingAuditContext } from "../dto/types";
|
|
import type { AuditActorType } from "../repository/IAuditActorRepository";
|
|
import type {
|
|
BookingAuditAction,
|
|
BookingAuditType,
|
|
BookingAuditWithActor,
|
|
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;
|
|
userRepository: UserRepository;
|
|
bookingRepository: BookingRepository;
|
|
membershipRepository: MembershipRepository;
|
|
attendeeRepository: IAttendeeRepository;
|
|
log: ISimpleLogger;
|
|
credentialRepository: CredentialRepository;
|
|
}
|
|
|
|
type EnrichedAuditLog = {
|
|
id: string;
|
|
bookingUid: string;
|
|
type: BookingAuditType;
|
|
action: BookingAuditAction;
|
|
timestamp: string;
|
|
createdAt: string;
|
|
source: ActionSource;
|
|
operationId: string;
|
|
displayJson?: Record<string, unknown> | null;
|
|
actionDisplayTitle: TranslationWithParams;
|
|
displayFields?: DisplayField[] | null;
|
|
actor: {
|
|
id: string;
|
|
type: AuditActorType;
|
|
userUuid: string | null;
|
|
attendeeId: number | null;
|
|
name: string | null;
|
|
createdAt: Date;
|
|
displayName: string;
|
|
displayEmail: string | null;
|
|
displayAvatar: string | null;
|
|
};
|
|
impersonatedBy?: {
|
|
displayName: string;
|
|
displayEmail: string | null;
|
|
displayAvatar: string | null;
|
|
} | null;
|
|
hasError?: boolean;
|
|
};
|
|
|
|
export type DisplayBookingAuditLog = EnrichedAuditLog;
|
|
|
|
/**
|
|
* BookingAuditViewerService - Service for viewing and formatting booking audit logs
|
|
*/
|
|
export class BookingAuditViewerService {
|
|
private readonly actionServiceRegistry: BookingAuditActionServiceRegistry;
|
|
private readonly bookingAuditRepository: IBookingAuditRepository;
|
|
private readonly userRepository: UserRepository;
|
|
private readonly bookingRepository: BookingRepository;
|
|
private readonly membershipRepository: MembershipRepository;
|
|
private readonly attendeeRepository: IAttendeeRepository;
|
|
private readonly credentialRepository: CredentialRepository;
|
|
private readonly rescheduledAuditActionService: RescheduledAuditActionService;
|
|
private readonly accessService: BookingAuditAccessService;
|
|
private readonly log: BookingAuditViewerServiceDeps["log"];
|
|
|
|
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
|
|
this.bookingAuditRepository = deps.bookingAuditRepository;
|
|
this.userRepository = deps.userRepository;
|
|
this.bookingRepository = deps.bookingRepository;
|
|
this.membershipRepository = deps.membershipRepository;
|
|
this.attendeeRepository = deps.attendeeRepository;
|
|
this.credentialRepository = deps.credentialRepository;
|
|
this.log = deps.log;
|
|
this.rescheduledAuditActionService = new RescheduledAuditActionService();
|
|
this.accessService = new BookingAuditAccessService({
|
|
bookingRepository: this.bookingRepository,
|
|
membershipRepository: this.membershipRepository,
|
|
});
|
|
this.actionServiceRegistry = new BookingAuditActionServiceRegistry();
|
|
}
|
|
|
|
/**
|
|
* Get audit logs for a booking with full enrichment and formatting
|
|
* Handles permission checks, fetches logs, enriches actors, and formats display
|
|
*
|
|
* For bookings created from a reschedule (has fromReschedule field), this also
|
|
* fetches the last RESCHEDULED log from the previous booking and includes it
|
|
* as the first log entry with "rescheduled from" context.
|
|
*/
|
|
async getAuditLogsForBooking(params: {
|
|
bookingUid: string;
|
|
userId: number;
|
|
userEmail: string;
|
|
userTimeZone: string;
|
|
organizationId: number | null;
|
|
}): Promise<{ bookingUid: string; auditLogs: DisplayBookingAuditLog[] }> {
|
|
const { bookingUid, userId, userTimeZone, organizationId } = params;
|
|
await this.accessService.assertPermissions({
|
|
bookingUid,
|
|
userId,
|
|
organizationId,
|
|
});
|
|
|
|
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, dbStore);
|
|
} catch (error) {
|
|
this.log.error(
|
|
`Failed to enrich audit log ${log.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
return this.buildFallbackAuditLog(log);
|
|
}
|
|
})
|
|
);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
return {
|
|
bookingUid: params.bookingUid,
|
|
auditLogs: enrichedAuditLogs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Enriches a single audit log with actor information and formatted display data
|
|
*/
|
|
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);
|
|
|
|
const actionDisplayTitle = await actionService.getDisplayTitle({
|
|
storedData: parsedData,
|
|
userTimeZone,
|
|
dbStore,
|
|
});
|
|
|
|
const displayJson = actionService.getDisplayJson
|
|
? actionService.getDisplayJson({ storedData: parsedData, userTimeZone })
|
|
: null;
|
|
|
|
const displayFields = actionService.getDisplayFields
|
|
? await actionService.getDisplayFields({ storedData: parsedData, dbStore })
|
|
: null;
|
|
|
|
const impersonatedBy = this.enrichImpersonator({ context: log.context, dbStore });
|
|
|
|
return {
|
|
id: log.id,
|
|
bookingUid: log.bookingUid,
|
|
type: log.type,
|
|
action: log.action,
|
|
timestamp: log.timestamp.toISOString(),
|
|
createdAt: log.createdAt.toISOString(),
|
|
source: log.source,
|
|
operationId: log.operationId,
|
|
displayJson,
|
|
actionDisplayTitle,
|
|
displayFields,
|
|
actor: {
|
|
id: log.actor.id,
|
|
type: log.actor.type,
|
|
userUuid: log.actor.userUuid,
|
|
attendeeId: log.actor.attendeeId,
|
|
name: log.actor.name,
|
|
createdAt: log.actor.createdAt,
|
|
displayName: enrichedActor.displayName,
|
|
displayEmail: enrichedActor.displayEmail,
|
|
displayAvatar: enrichedActor.displayAvatar,
|
|
},
|
|
impersonatedBy,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds a minimal fallback audit log when enrichment fails.
|
|
* Returns a log entry with hasError: true and basic information from the raw log.
|
|
*/
|
|
private buildFallbackAuditLog(log: BookingAuditWithActor): EnrichedAuditLog {
|
|
return {
|
|
id: log.id,
|
|
bookingUid: log.bookingUid,
|
|
type: log.type,
|
|
action: log.action,
|
|
timestamp: log.timestamp.toISOString(),
|
|
createdAt: log.createdAt.toISOString(),
|
|
source: log.source,
|
|
operationId: log.operationId,
|
|
displayJson: null,
|
|
actionDisplayTitle: {
|
|
key: "booking_audit_action.error_processing",
|
|
params: { actionType: log.action },
|
|
},
|
|
displayFields: null,
|
|
actor: {
|
|
id: log.actor.id,
|
|
type: log.actor.type,
|
|
userUuid: log.actor.userUuid,
|
|
attendeeId: log.actor.attendeeId,
|
|
name: log.actor.name,
|
|
createdAt: log.actor.createdAt,
|
|
displayName: log.actor.name || "Unknown",
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
},
|
|
impersonatedBy: null,
|
|
hasError: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds a "rescheduled from" log entry for bookings created from a reschedule.
|
|
* Fetches the RESCHEDULED log from the previous booking and transforms it
|
|
* to show "rescheduled from" context for the current booking.
|
|
*/
|
|
private async buildRescheduledFromLog({
|
|
fromRescheduleUid,
|
|
currentBookingUid,
|
|
userTimeZone,
|
|
dbStore,
|
|
rescheduledLogs,
|
|
}: {
|
|
fromRescheduleUid: string;
|
|
currentBookingUid: string;
|
|
userTimeZone: string;
|
|
dbStore: EnrichmentDataStore;
|
|
rescheduledLogs: BookingAuditWithActor[];
|
|
}): Promise<EnrichedAuditLog | null> {
|
|
// Find the specific log that created this booking by matching rescheduledToUid
|
|
const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({
|
|
rescheduledLogs,
|
|
rescheduledToBookingUid: currentBookingUid,
|
|
});
|
|
|
|
if (!rescheduledLog) {
|
|
this.log.error(`No rescheduled log found for booking ${fromRescheduleUid} -> ${currentBookingUid}`);
|
|
// Instead of crashing, we ignore because it is important to be able to access other logs as well.
|
|
return null;
|
|
}
|
|
|
|
const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone, dbStore);
|
|
const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data);
|
|
|
|
// Transform the display JSON to show "rescheduled from" instead of "rescheduled to"
|
|
// by replacing rescheduledToUid with rescheduledFromUid
|
|
const transformedDisplayJson = enrichedLog.displayJson
|
|
? {
|
|
...enrichedLog.displayJson,
|
|
rescheduledFromUid: fromRescheduleUid,
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
...enrichedLog,
|
|
// Override bookingUid to associate with the current booking being viewed
|
|
bookingUid: currentBookingUid,
|
|
displayJson: transformedDisplayJson,
|
|
// Use a different translation key to show "Rescheduled from" instead of "Rescheduled"
|
|
actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({
|
|
fromRescheduleUid,
|
|
userTimeZone,
|
|
storedData: parsedData,
|
|
}),
|
|
};
|
|
}
|
|
|
|
private enrichImpersonator({
|
|
context,
|
|
dbStore,
|
|
}: {
|
|
context: BookingAuditContext | null;
|
|
dbStore: EnrichmentDataStore;
|
|
}): {
|
|
displayName: string;
|
|
displayEmail: string | null;
|
|
displayAvatar: string | null;
|
|
} | null {
|
|
if (!context?.impersonatedBy) {
|
|
return null;
|
|
}
|
|
|
|
const impersonatorUser = dbStore.getUserByUuid(context.impersonatedBy);
|
|
if (!impersonatorUser) {
|
|
return {
|
|
displayName: "Deleted User",
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
displayName: impersonatorUser.name || impersonatorUser.email,
|
|
displayEmail: impersonatorUser.email,
|
|
displayAvatar: impersonatorUser.avatarUrl || null,
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Collect all data requirements from audit logs and action services
|
|
*/
|
|
private collectDataRequirements(auditLogs: BookingAuditWithActor[]): DataRequirements {
|
|
let actorRequirements: DataRequirements[] = [];
|
|
let serviceRequirements: DataRequirements[] = [];
|
|
let contextRequirements: DataRequirements[] = [];
|
|
for (const log of auditLogs) {
|
|
actorRequirements.push(getActorDataRequirements(log.actor));
|
|
|
|
const context = log.context;
|
|
if (context?.impersonatedBy) {
|
|
contextRequirements.push({ userUuids: [context.impersonatedBy] });
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|