import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { Readable } from 'stream'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { getTemplateMeta } from '../../contracts/contract-template.registry'; import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { MinioService } from '../minio/minio.service'; import { FilesService } from '../files/files.service'; import { FileRecord } from '../files/entities/file.entity'; import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; import { clearanceSettingCode } from './clearance.util'; import { ContractViewDto } from './dto/contract-view.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ContractSignerRole } from './entities/booking-contract-signature.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { SignaturesService } from '../signatures/signatures.service'; @Injectable() export class BookingContractService { private readonly logger = new Logger(BookingContractService.name); constructor( private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, private readonly minioService: MinioService, private readonly templateResolver: ContractTemplateResolver, private readonly viewModelBuilder: ContractViewModelBuilder, private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, private readonly signaturesService: SignaturesService, ) {} buildContractSummary(booking: Booking): string { const direction = booking.tradeDirection === 'IMPORT' ? 'Import' : booking.tradeDirection === 'EXPORT' ? 'Export' : booking.tradeDirection; const cargo = booking.cargoType; const isBulk = booking.freightType === 'BULK'; let cargoLabel: string; if (isBulk) { cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`; } else { const lines = booking.bookingContainers?.map((bc) => { const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container'; return `${bc.quantity}× ${label}`; }) ?? []; cargoLabel = lines.length > 0 ? `Container (${lines.join(', ')})` : 'Container (Standard)'; } return `Operation: ${direction} | Cargo Type: ${cargoLabel}`; } async getSummary(bookingId: string): Promise<{ summary: string }> { const booking = await this.requireBooking(bookingId); const summary = booking.contractSummary ?? this.buildContractSummary(booking); return { summary }; } async getContractView( bookingId: string, viewerUserId?: string, ): Promise { const { view } = await this.viewModelBuilder.build(bookingId); await this.inlineSignatureImages(view.signatures); const html = this.renderer.render(view); const savedSignature = viewerUserId ? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined) : undefined; return { bookingId: view.bookingId, reference: view.reference, status: view.status, templateKey: view.templateKey, title: view.template.title, html, canSignCustomer: view.canSignCustomer, canSignStaff: view.canSignStaff, hasContractDocument: view.hasContractDocument, signatures: view.signatures, savedSignature, pricingSchedule: view.pricing as unknown as Record, }; } async generateContract(bookingId: string): Promise { const booking = await this.requireBooking(bookingId); assertBookingStatus(booking, ['APPROVED']); const templateKey = this.templateResolver.resolve(booking); const summary = this.buildContractSummary(booking); // PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract // from becoming ready — the document is (re)rendered lazily on view/download. try { await this.upsertContractPdf(bookingId, booking.reference, templateKey); } catch (err) { this.logger.warn( `Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`, ); } const now = new Date(); const updated = await this.bookingsRepository.update(bookingId, { status: 'CONTRACT_READY', contractSummary: summary, contractTemplateKey: templateKey, contractGeneratedAt: now, } as never); return updated!; } async streamContract(bookingId: string) { const booking = await this.requireBooking(bookingId); const templateKey = booking.contractTemplateKey ?? this.templateResolver.resolve(booking); const record = await this.upsertContractPdf( bookingId, booking.reference, templateKey, ); return this.filesService.streamById(record.id); } async signContract( bookingId: string, dto: SignContractDto, options: { signerUserId?: string; ipAddress?: string }, ): Promise { const booking = await this.requireBooking(bookingId); const role = dto.role as ContractSignerRole; if (role === 'CUSTOMER') { assertBookingStatus(booking, ['CONTRACT_READY']); const existing = await this.bookingsRepository.findContractSignature( bookingId, 'CUSTOMER', ); if (existing) { throw new BadRequestException('Customer has already signed this contract'); } } else { assertBookingStatus(booking, ['SIGNED_CUSTOMER']); const existing = await this.bookingsRepository.findContractSignature( bookingId, 'STAFF', ); if (existing) { throw new BadRequestException('Staff has already signed this contract'); } } const buffer = this.decodeSignatureImage(dto.signatureImageBase64); const sigFile: Express.Multer.File = { fieldname: `signature_${role.toLowerCase()}`, originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`, encoding: '7bit', mimetype: 'image/png', size: buffer.length, buffer, stream: Readable.from(buffer), destination: '', filename: '', path: '', }; const fileRecord = await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff', file: sigFile, }); const now = new Date(); await this.bookingsRepository.saveContractSignature({ bookingId, signerRole: role, signerUserId: options.signerUserId ?? null, signerDisplayName: dto.signerDisplayName, signedAt: now, signatureFileId: fileRecord.id, consentText: dto.consentText ?? null, ipAddress: options.ipAddress ?? null, }); // Persist the just-used signature to the signer's reusable profile so they // don't have to redraw it on the next contract. Best-effort: a failure here // must never block contract execution. if (options.signerUserId) { try { await this.signaturesService.upsertForUser({ userId: options.signerUserId, signerDisplayName: dto.signerDisplayName, signatureImageBase64: dto.signatureImageBase64, }); } catch (err) { this.logger.warn( `Could not save reusable signature for user ${options.signerUserId}: ${err}`, ); } } const updates: Record = {}; // Whether a document-clearance gate applies (IMPORT/EXPORT bookings). When it // does, the counter-signed booking goes to AWAITING_DOCUMENTS for the customer // to upload clearance documents instead of straight into the batch pipeline. const includesCustoms = booking.serviceType?.includesCustoms ?? false; const clearanceCode = clearanceSettingCode( booking.tradeDirection, booking.freightType, includesCustoms, ); if (role === 'CUSTOMER') { updates.status = 'SIGNED_CUSTOMER'; updates.customerSignedAt = now; } else { updates.fullyExecutedAt = now; updates.marketingApprovedAt = now; updates.marketingApprovedById = options.signerUserId ?? null; updates.lockedAt = now; updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED'; } const updated = await this.bookingsRepository.update(bookingId, updates as never); // Only the non-clearance (legacy/domestic) path enters the batch pipeline now; // clearance bookings enter operations after the GL document gate. if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) { this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId); } try { await this.upsertContractPdf( bookingId, booking.reference, booking.contractTemplateKey ?? this.templateResolver.resolve(booking), ); } catch (err) { this.logger.warn( `Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, ); } return updated!; } async getSignatures(bookingId: string) { const rows = await this.bookingsRepository.findContractSignatures(bookingId); const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r)); await this.inlineSignatureImages(views); return { signatures: views }; } private async upsertContractPdf( bookingId: string, reference: string, templateKey: string, ): Promise { const { view } = await this.viewModelBuilder.build(bookingId); view.templateKey = templateKey; view.template = getTemplateMeta(templateKey); await this.inlineSignatureImages(view.signatures); const html = this.renderer.render(view); const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); const file: Express.Multer.File = { fieldname: 'contract', originalname: `contract-${reference}.pdf`, encoding: '7bit', mimetype: 'application/pdf', size: pdfBuffer.length, buffer: pdfBuffer, stream: Readable.from(pdfBuffer), destination: '', filename: '', path: '', }; return this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'contract', file, }); } private async inlineSignatureImages( signatures: Array<{ signatureImageUrl?: string | null }>, ): Promise { for (const sig of signatures) { if (!sig.signatureImageUrl) continue; try { if (sig.signatureImageUrl.startsWith('data:')) continue; const objectName = this.minioService.getObjectNameFromUrl( sig.signatureImageUrl, ); const stream = await this.minioService.getFileStream(objectName); const buffer = await this.streamToBuffer(stream); sig.signatureImageUrl = `data:image/png;base64,${buffer.toString( 'base64', )}`; } catch { /* keep original url */ } } } private streamToBuffer(stream: Readable): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on('data', (chunk: Buffer | string) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks))); }); } private decodeSignatureImage(base64: string): Buffer { const raw = base64.includes(',') ? base64.split(',')[1]! : base64; return Buffer.from(raw, 'base64'); } private async requireBooking(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) throw new NotFoundException(`Booking ${id} not found`); return booking; } }