import { Injectable, Logger } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { Freight } from '@edr/types'; import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; import { BookingClearanceEvent, ClearanceEventActorType, } from './entities/booking-clearance-event.entity'; export interface RecordClearanceEventInput { bookingId: string; action: string; /** Human sentence for the History tab, frozen at write time. */ label: string; actorType?: ClearanceEventActorType; /** IAM user id (staff or portal customer); name is resolved here. */ actorId?: string | null; metadata?: Record | null; /** Join the caller's transaction so the event commits (or rolls back) with the action. */ manager?: EntityManager; } /** * The clearance History tab's write/read path. Every clearance mutation calls * {@link record} — document reviews, phased workflow steps, customer charges. * Recording is deliberately NOT fire-and-forget: the insert shares the caller's * transaction when a manager is passed, and otherwise a failed insert fails the * action, because a silent gap in an audit trail is worse than a retry. */ @Injectable() export class ClearanceEventService { private readonly logger = new Logger(ClearanceEventService.name); constructor(private readonly dataSource: DataSource) {} async record(input: RecordClearanceEventInput): Promise { const mg = input.manager ?? this.dataSource.manager; const actorName = input.actorId ? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get( input.actorId, ) ?? null) : null; await mg.save( mg.create(BookingClearanceEvent, { bookingId: input.bookingId, action: input.action, label: input.label, actorType: input.actorType ?? 'STAFF', actorId: input.actorId ?? null, actorName, metadata: input.metadata ?? null, }), ); this.logger.log( `clearance-history ${input.action} on booking ${input.bookingId}${ actorName ? ` by ${actorName}` : '' }`, ); } /** History for one booking, newest first. */ async list(bookingId: string): Promise { const rows = await this.dataSource .getRepository(BookingClearanceEvent) .find({ where: { bookingId }, order: { createdAt: 'DESC' } }); // Rows whose actor name failed to resolve at write time get one more try. const missing = rows .filter((r) => !r.actorName && r.actorId) .map((r) => r.actorId as string); const names = missing.length ? await resolveIamUserNames(this.dataSource, missing).catch( () => new Map(), ) : new Map(); return rows.map((r) => ({ id: r.id, action: r.action, label: r.label, actorType: r.actorType, actorName: r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null), metadata: r.metadata ?? null, at: r.createdAt.toISOString(), })); } }