import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; import { insertWithGeneratedReference } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractViewModel } from '../../contracts/contract-view-model.builder'; import { MinioService } from '../minio/minio.service'; import { FileRecord } from '../files/entities/file.entity'; import { assertCanApproveContractStep, assertFreightPermission, canEditContractStep, } from '../../common/freight-permission.util'; import { FREIGHT_PERMS, forFreightType, } from '../../seed/freight-permissions.registry'; import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; import { contractClearanceSettingCode } from './contract-clearance.util'; import { Contract, ContractDocumentArticle, ContractDocumentSnapshot, ContractDocumentSnapshotInput, } from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { SignContractDto } from './dto/sign-contract.dto'; /** The editable contract-document draft returned for the accept/edit dialog. */ export interface ContractDocumentDraft { documentTitle: string | null; whereasClauses: string[]; articles: ContractDocumentArticle[]; code: string | null; name: string | null; /** True when THIS caller may not edit — the inverse of `editableByMe`. */ locked: boolean; /** Whether the requesting user is the approver whose turn it is to edit. */ editableByMe: boolean; /** Role holding editing rights right now, for "locked because…" messaging. */ nextApproverRole: string | null; generatedAt: Date | null; status: string; } /** * Dropdown-settings code holding the admin-configured contract validity options * (each option's `value` is a day count). The staff accept dialog reads the same * code, so accept can only use a configured duration. See the seed migration * `SeedContractValidityPeriods`. */ const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods'; /** * Approval chains are configured in IAM position types, so a step's role no * longer maps onto the contract's fixed approver columns. These sets keep those * legacy columns populated for the roles that still correspond to one — both the * original role strings on historical rows and the position types that replaced * them. Steps outside these sets are recorded only in `contract_approval_steps`, * which is the source of truth. */ const LEGACY_STAFF_ROLES = new Set([ 'LINE_STAFF', 'employee', 'teamLeader', 'officeHead', 'recordOfficer', ]); const LEGACY_DIRECTOR_ROLES = new Set([ 'DIRECTOR', 'director', 'operation-director', ]); const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']); /** * Mask a phone for display — keep the last 4 digits, star the rest * (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing * code went without echoing the company's full registered number back to the UI. */ function maskPhone(phone: string): string { const trimmed = phone.trim(); if (trimmed.length <= 4) return trimmed; return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`; } /** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */ function maskEmail(email: string): string { const [local, domain] = email.trim().split('@'); if (!domain) return email.trim(); return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`; } /** * Where the signing code went, for the "we sent a code to …" line in the UI. * Both contacts are listed when both were used — a signer who only watches their * handset otherwise has no idea the email carries the same code. */ function maskSignerContacts(contacts: { phone?: string; email?: string }): string { return [ contacts.email ? maskEmail(contacts.email) : null, contacts.phone ? maskPhone(contacts.phone) : null, ] .filter(Boolean) .join(' and '); } /** Status-machine guard mirroring booking-status.util. */ function assertContractStatus(contract: Contract, allowed: string[]): void { if (!allowed.includes(contract.status)) { throw new ConflictException( `Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`, ); } } @Injectable() export class ContractTransitionService { private readonly logger = new Logger(ContractTransitionService.name); constructor( private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, private readonly approvalRulesService: ApprovalRulesService, private readonly cargoTypesService: CargoTypesService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly filesService: FilesService, private readonly signaturesService: SignaturesService, private readonly milestoneService: ClearanceMilestoneService, private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder, private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, private readonly otpService: OtpService, private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, @InjectDataSource() private readonly dataSource: DataSource, ) {} /** * The contacts the signing OTP is sent to and verified against: the signer's * own IAM account phone AND email. One code goes to both and either delivery * verifies it, so a signer whose SMS is delayed can still complete from their * inbox instead of abandoning a ready contract. * * H12(b): resolved server-side from the authenticated user id, never from the * request body — caller-supplied contacts would let an attacker point the code * at their own phone or mailbox. Ownership is already gated separately by * {@link ContractsService.assertCustomerCanAccessContract}, so this binds the * signature to the *person* signing rather than to a company landline that may * be shared, stale, or imported from eTrade. */ private async resolveSignerContacts( signerUserId?: string, ): Promise<{ phone?: string; email?: string }> { if (!signerUserId) { // Unreachable in practice (the ownership gate rejects a missing user // first), but never fall back to another account if it ever changes. throw new BadRequestException('Authentication required to sign'); } const rows: Array<{ phone_number: string | null; email: string | null }> = await this.dataSource.query( `SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`, [signerUserId], ); const phone = rows[0]?.phone_number?.trim(); const email = rows[0]?.email?.trim(); if (!phone && !email) { throw new BadRequestException( 'Your account has no registered phone number or email. Add one in Settings → Account before signing.', ); } return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) }; } /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ async submit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']); await this.pricingService.generatePrice(contractId); await this.pricingService.freezeRateSnapshots(contractId); await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); const updated = await this.contractsService.findById(contractId); this.notifier.submittedToStaff(updated); return updated; } /** Confirm a price change before submit (mirrors booking confirm-submit). */ async confirmSubmit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']); await this.pricingService.generatePrice(contractId); await this.pricingService.freezeRateSnapshots(contractId); await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); const updated = await this.contractsService.findById(contractId); this.notifier.submittedToStaff(updated); return updated; } /** * Line staff accepts intake: set the validity window from validityDays and * instantiate the approval steps from approval_rules → PENDING_APPROVAL. */ async staffAccept( contractId: string, actorId: string, validityDays: number, documentSnapshot?: ContractDocumentSnapshotInput | null, user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); // The route guard passes on either arm; the contract's freight type decides // which one is actually required (accept bulk ≠ accept container). assertFreightPermission( user, forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType), ); assertContractStatus(contract, ['SUBMITTED']); if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( 'A contract validity (in days) is required to accept this contract.', ); } await this.assertValidityDaysConfigured(validityDays); const validFrom = new Date(); const validUntil = new Date(validFrom); validUntil.setDate(validUntil.getDate() + validityDays); await this.instantiateApprovalSteps(contract); // Freeze the contract document for THIS contract only. Staff may have edited // the articles in the accept dialog; otherwise the live template is captured // as-is so later template edits never change an in-flight contract. The // shared six templates are never written here. const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot); await this.contractsRepository.update(contractId, { status: 'PENDING_APPROVAL', approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, documentSnapshot: snapshot, } as never); const updated = await this.contractsService.findById(contractId); this.notifier.accepted(updated); return updated; } // ── Per-contract document snapshot (US: edit articles for one contract) ───── /** * The editable document draft for the accept/edit dialog: the frozen snapshot * if one exists, else the live active template resolved for this contract's * direction/freight pair. `locked` flips true once the document may no longer * be edited (an approver has acted, or the contract has left the pre-approval * window). */ async getContractDocumentDraft( contractId: string, user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); const snapshot = (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? (await this.resolveDocumentSnapshot(contract)); const editableByMe = await this.documentIsEditableBy(contract, user); return { documentTitle: snapshot?.documentTitle ?? null, whereasClauses: snapshot?.whereasClauses ?? [], articles: snapshot?.articles ?? [], code: snapshot?.code ?? null, name: snapshot?.name ?? null, locked: !editableByMe, editableByMe, nextApproverRole: await this.nextApproverRole(contract), generatedAt: contract.contractGeneratedAt ?? null, status: contract.status, }; } /** * Replace this contract's document articles from the editor. Per-contract * only — it writes the contract's own snapshot and never the shared templates. * Allowed while the document is still editable (PENDING_APPROVAL, no approver * has acted). */ async updateContractDocument( contractId: string, input: ContractDocumentSnapshotInput, user?: TCurrentUser | null, actorId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL']); await this.assertDocumentEditable(contract, user); const current = (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? (await this.resolveDocumentSnapshot(contract)); const merged: ContractDocumentSnapshotInput = { code: current?.code ?? null, name: input.name ?? current?.name ?? null, documentTitle: input.documentTitle ?? current?.documentTitle ?? null, whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [], articles: input.articles ?? current?.articles ?? [], }; const next = this.normalizeSnapshot(merged); await this.contractsRepository.update(contractId, { documentSnapshot: next, } as never); // Audit the edit after it lands. Recording history must never break the // edit itself, so the history service swallows its own failures. const step = await this.contractsRepository.findNextPendingApprovalStep( contractId, ); await this.documentHistory.record({ contractId, before: current, after: next, actorId: actorId ?? null, actorRole: step?.requiredRole ?? null, stepId: step?.id ?? null, }); return this.contractsService.findById(contractId); } /** * Build the per-contract document snapshot. Prefer the staff's edited articles * from the dialog; otherwise freeze the active template matching the * contract's direction/freight. Returns null when no active template exists * (the renderer then falls back to the built-in generic layout at render time). */ private async resolveDocumentSnapshot( contract: Contract, provided?: ContractDocumentSnapshotInput | null, ): Promise { if (provided && (provided.articles?.length ?? 0) > 0) { return this.normalizeSnapshot(provided); } const active = await this.contractTemplates.findActiveForContract( contract.tradeDirection, contract.freightType, ); if (!active) return null; return { code: active.code, name: active.name, documentTitle: active.documentTitle, whereasClauses: active.whereasClauses ?? [], articles: this.normalizeArticles(active.articles ?? []), }; } private normalizeSnapshot( input: ContractDocumentSnapshotInput, ): ContractDocumentSnapshot { return { code: input.code ?? null, name: input.name ?? null, documentTitle: input.documentTitle ?? null, whereasClauses: Array.isArray(input.whereasClauses) ? input.whereasClauses .map((c) => String(c)) .filter((c) => c.trim().length > 0) : [], articles: this.normalizeArticles(input.articles ?? []), }; } /** Re-key ids and renumber order sequentially, dropping empty-title rows. */ private normalizeArticles( articles: Array<{ id?: string; title?: string; body?: string; order?: number }>, ): ContractDocumentArticle[] { return articles .filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0) .map((a, index) => ({ id: a.id ?? randomUUID(), title: (a.title ?? '').trim(), body: a.body ?? '', order: index + 1, })); } /** * The contract document stays editable for the whole approval chain, but only * by the approver whose turn it is: whoever can action the next pending step. * Approving therefore hands editing rights to the next approver in the chain. * * Edits never reset approvals already given — earlier approvers stay approved. */ private async documentIsEditableBy( contract: Contract, user?: TCurrentUser | null, ): Promise { if (contract.status === 'SUBMITTED') return true; if (contract.status !== 'PENDING_APPROVAL') return false; const next = await this.contractsRepository.findNextPendingApprovalStep( contract.id, ); if (!next) return false; if (!user) return false; // Strict match: ONLY the approver whose turn it is (the next pending step's // role) may edit. Using the looser approve gate here let any approver who // held a contract-approve permission keep the edit button after acting — // approval must hand edit rights to the next approver, not share them. return canEditContractStep(user, next.requiredRole); } /** The role that currently holds editing rights, for UI messaging. */ private async nextApproverRole(contract: Contract): Promise { if (contract.status !== 'PENDING_APPROVAL') return null; const next = await this.contractsRepository.findNextPendingApprovalStep( contract.id, ); return next?.requiredRole ?? null; } private async assertDocumentEditable( contract: Contract, user?: TCurrentUser | null, ): Promise { if (await this.documentIsEditableBy(contract, user)) return; const role = await this.nextApproverRole(contract); throw new ConflictException( role ? `The contract document can only be edited by the current approver (${role}).` : 'The contract document is locked — the contract has advanced beyond approval.', ); } /** * Ensure the chosen validity (days) is one of the admin-configured options in * the `contract_validity_periods` dropdown setting. If the setting is missing * or has no options yet, fall back to the DTO range check (already applied) so * acceptance is never hard-blocked before an admin configures the list. */ private async assertValidityDaysConfigured(validityDays: number): Promise { let setting; try { setting = await this.dropdownSettingsService.getByCode( CONTRACT_VALIDITY_PERIODS_CODE, ); } catch { // Not configured yet — keep the flow working with the DTO range only. return; } const allowed = (setting.children ?? []) .map((o) => Number(o.value)) .filter((n) => Number.isFinite(n)); if (allowed.length === 0) return; if (!allowed.includes(validityDays)) { throw new BadRequestException( `Validity ${validityDays} days is not a configured option. Allowed: ${allowed .sort((a, b) => a - b) .join(', ')} days.`, ); } } /** * Build contract approval steps from the system approval_rules chain (US-06: * container → line staff + director; bulk → directors + CEO). Mirrors the * booking transition's instantiateApprovalSteps but writes contract steps. */ private async instantiateApprovalSteps(contract: Contract): Promise { if ((contract.approvalSteps?.length ?? 0) > 0) return; const cargoTypeId = (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null; // Resolve the chain from the cargo type flag only. let requiresDirectorApproval = false; if (cargoTypeId) { const cargoType = await this.cargoTypesService.findById(cargoTypeId); requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false; } const chain = await this.approvalRulesService.findChain(requiresDirectorApproval); if (chain.length === 0) { throw new BadRequestException( `Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`, ); } for (const rule of chain) { await this.contractsRepository.createApprovalStep({ contractId: contract.id, stepOrder: rule.stepOrder, requiredRole: rule.requiredRole, blocksRole: rule.blocksRole ?? null, status: 'PENDING', }); } } async requestChanges( contractId: string, note: string, actorId: string, user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); assertFreightPermission( user, forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType), ); assertContractStatus(contract, ['SUBMITTED']); await this.contractsRepository.createReviewNote( contractId, note, 'CHANGES_REQUESTED', actorId, 'STAFF', ); await this.contractsRepository.update(contractId, { status: 'CHANGES_REQUESTED', } as never); const updated = await this.contractsService.findById(contractId); this.notifier.changesRequested(updated, note); return updated; } async reject( contractId: string, reason: string, actorId: string, user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); assertFreightPermission( user, forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType), ); assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']); await this.contractsRepository.createReviewNote( contractId, reason, 'REJECTION', actorId, 'STAFF', ); await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); const updated = await this.contractsService.findById(contractId); this.notifier.rejected(updated, reason); return updated; } /** * Reject one approval step (line staff / director / CEO). The rejecting * approver must supply a reason, and picks where the rejection lands: * * - **To the customer** (`returnToStepId` omitted — the only option for the * first approver): terminal. The whole contract moves to REJECTED with a * REJECTION review note visible to the customer, who must resubmit. * - **To an earlier approver** (`returnToStepId` = an already-APPROVED * earlier step): internal send-back. That step and everything after it * reset to PENDING and the chain re-runs from there; the contract stays * PENDING_APPROVAL and the customer never sees it. E.g. the director can * return a contract to line staff, who fix it and approve again, after * which every later stage re-approves in order. */ async rejectStep( contractId: string, stepId: string, actorId: string, reason: string, returnToStepId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step) throw new BadRequestException('Approval step not found'); // Only the approver whose turn it is may reject — same ordering rule as // approveStep. Without this, an already-actioned or future step could be // "rejected" and wipe chain state it never owned. const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); if (!next || next.id !== step.id) { throw new BadRequestException( 'Only the current pending approval step can be rejected', ); } if (returnToStepId) { return this.sendBackToStep(contract, step, actorId, reason, returnToStepId); } await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); await this.contractsRepository.createReviewNote( contractId, reason, 'REJECTION', actorId, 'STAFF', ); await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); const updated = await this.contractsService.findById(contractId); this.notifier.rejected(updated, reason); return updated; } /** * Internal send-back branch of rejectStep: return the contract to an earlier, * already-approved stage of the chain instead of rejecting it outright. * Deliberately NOT the terminal path: the contract is still alive and there * is no customer-facing REJECTION note — the trail is a staff note plus a * backoffice inbox ping. */ private async sendBackToStep( contract: Contract, rejectingStep: ContractApprovalStep, actorId: string, reason: string, returnToStepId: string, ): Promise { const target = await this.contractsRepository.findApprovalStepById( contract.id, returnToStepId, ); if (!target) throw new BadRequestException('Return-to approval step not found'); if (target.stepOrder >= rejectingStep.stepOrder) { throw new BadRequestException( 'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId', ); } if (target.status !== 'APPROVED') { throw new BadRequestException( `Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`, ); } // Staff-visible trail. Written before the reset so the reason survives the // wipe of per-step notes. await this.contractsRepository.createReviewNote( contract.id, `Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`, 'STAFF_NOTE', actorId, 'STAFF', ); // Chain re-runs from the target stage: it and every later step (including // the rejecting one) go back to PENDING. Legacy approved-by columns are // left stale on purpose — approval steps are the source of truth and the // columns get re-stamped on re-approval. await this.contractsRepository.resetApprovalStepsFrom( contract.id, target.stepOrder, ); // A send-back can only happen mid-chain, so the contract must remain (or // return to) PENDING_APPROVAL — relevant when rejecting from // APPROVED_PENDING_SIGNATURE. await this.contractsRepository.update(contract.id, { status: 'PENDING_APPROVAL', } as never); const updated = await this.contractsService.findById(contract.id); this.notifier.sentBackToStep(updated, target.requiredRole, reason); return updated; } /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, stepId: string, actorId: string, authUser?: TCurrentUser, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step || step.status !== 'PENDING') { throw new BadRequestException('Approval step not found or already actioned'); } const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); if (!next || next.id !== step.id) { throw new BadRequestException('Approval steps must be completed in order'); } // The role is the step's own — never the caller's claim about themselves. const requiredRole = step.requiredRole; if (authUser) { assertCanApproveContractStep(authUser, requiredRole); } await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); // Record who acted on this step, but DO NOT advance the contract status here — // approving one step must not finalize the chain while later steps are still // pending. Status only moves to APPROVED once every step in the chain is // complete; until then the contract stays in PENDING_APPROVAL so the next // required approver can act. // // `contract_approval_steps` is the source of truth for who approved what — a // chain is an arbitrary sequence of position types and cannot be represented // by fixed columns. The legacy columns below are still stamped, best-effort, // for the three roles that map onto them so older readers keep working. const updates: Record = {}; const now = new Date(); if (LEGACY_STAFF_ROLES.has(requiredRole)) { updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; } else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; } else if (LEGACY_CEO_ROLES.has(requiredRole)) { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId); updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL'; if (Object.keys(updates).length > 0) { await this.contractsRepository.update(contractId, updates as never); } const updated = await this.contractsService.findById(contractId); if (allDone) { this.notifier.approved(updated); // Final approval is what produces the contract PDF — until now there was // only a live preview. The approval steps are already committed, so a // render failure must not roll them back; surface it instead of swallowing // it, since an APPROVED contract with no document needs operator action. try { return await this.finalizeApprovedContract(contractId); } catch (err) { this.logger.error( `Contract PDF generation failed after final approval for ${updated.reference}: ${err}`, ); throw new ServiceUnavailableException( 'All approvals were recorded, but generating the contract PDF failed. ' + 'Retry generation from the contract page.', ); } } return updated; } /** * Retry path for a contract that finished approval but whose PDF failed to * render (Chromium unavailable, etc.). The normal flow generates the document * automatically on the final approval — there is no manual generate step * before that, only the live preview. */ async generateContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']); await this.renderContractDocument(contract); await this.contractsRepository.update(contractId, { status: 'CONTRACT_READY', } as never); return this.contractsService.findById(contractId); } /** * Render the contract PDF from the Contract aggregate (snapshot-driven), store * it via FilesService, and stamp the template key + generated timestamp. Never * changes status. Rendering is best-effort — a Chromium hiccup defers the file * (it re-renders on view/download) but the timestamp is still stamped. */ private async renderContractDocument( contract: Contract, options: { strict?: boolean } = {}, ): Promise { const { view } = await this.documentViewModelBuilder.build(contract.id); try { await this.upsertContractPdf(contract.id, contract.reference, view); } catch (err) { // Strict callers (final approval) need to know the PDF is missing — it is // the artifact of the completed chain, not a cache that can refill later. if (options.strict) throw err; this.logger.warn( `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`, ); } await this.contractsRepository.update(contract.id, { contractTemplateKey: view.templateKey, contractGeneratedAt: new Date(), } as never); } /** * Every approval step landed → generate the contract PDF, then CONTRACT_READY. * This is the only point at which the document is produced: approvers review a * live preview, and the final approval is what turns it into a PDF. Renders * unconditionally so the file reflects every edit made during the chain. */ private async finalizeApprovedContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); await this.renderContractDocument(contract, { strict: true }); await this.contractsRepository.update(contractId, { status: 'CONTRACT_READY', } as never); return this.contractsService.findById(contractId); } /** * Build the contract PDF view-model and rendered HTML for portal/backoffice * signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no * totals). Returns the view-model, the rendered HTML and the signature rows. */ async getContractDocumentView(contractId: string): Promise<{ view: ContractViewModel; html: string; signatures: ContractViewModel['signatures']; }> { const { view } = await this.documentViewModelBuilder.build(contractId); await this.inlineSignatureImages(view.signatures); const html = this.renderer.render(view); return { view, html, signatures: view.signatures }; } /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */ async streamContractPdf(contractId: string) { const contract = await this.contractsService.findById(contractId); const { view } = await this.documentViewModelBuilder.build(contractId); const record = await this.upsertContractPdf(contractId, contract.reference, view); return this.filesService.streamById(record.id); } /** * Rebuild the stored `contract` PDF from the current aggregate (now including * the latest signatures) so the downloaded/viewed file matches the live HTML * view. Best-effort — a Chromium hiccup must never fail the signing * transaction; the doc still re-renders live on /contract/view. */ private async regenerateContractPdf( contractId: string, reference: string, ): Promise { try { const { view } = await this.documentViewModelBuilder.build(contractId); await this.upsertContractPdf(contractId, reference, view); } catch (err) { this.logger.warn( `Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`, ); } } /** Render the contract PDF and upsert it as the `contract` file on the contract. */ private async upsertContractPdf( contractId: string, reference: string, view: ContractViewModel, ): Promise { 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: contractId, resource: 'contracts', code: 'contract', file, }); } /** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */ 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))); }); } /** Apply a digital signature row (mirrors booking-contract.service). */ private async applySignature( contract: Contract, dto: SignContractDto, options: { signerUserId?: string }, ): Promise { const role = dto.role as ContractSignerRole; // Resolve the signature image. The client may send a freshly-drawn image, or // omit it to reuse the signer's saved profile signature. Fall back to the // saved one whenever no image is supplied. let imageBase64 = dto.signatureImageBase64; let signerDisplayName = dto.signerDisplayName; if (!imageBase64 && options.signerUserId) { const saved = await this.signaturesService.getForUser(options.signerUserId); if (saved?.signatureImageUrl) { imageBase64 = saved.signatureImageUrl; signerDisplayName = signerDisplayName || saved.signerDisplayName; } } if (!imageBase64) { throw new BadRequestException( 'No signature provided and no saved signature found on the profile.', ); } const raw = imageBase64.includes(',') ? imageBase64.split(',')[1]! : imageBase64; const buffer = Buffer.from(raw, 'base64'); const sigFile: Express.Multer.File = { fieldname: `signature_${role.toLowerCase()}`, originalname: `signature-${role.toLowerCase()}-${contract.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: contract.id, resource: 'contracts', code: `signature_${role.toLowerCase()}`, file: sigFile, }); await this.contractsRepository.saveSignature({ contractId: contract.id, role, signerDisplayName, signedAt: new Date(), signatureFileId: fileRecord.id, consentText: dto.consentText ?? null, }); // Only (re)save the reusable profile signature when the signer drew a NEW // image. Reusing the saved signature must not rewrite it with itself. if (options.signerUserId && dto.signatureImageBase64) { try { await this.signaturesService.upsertForUser({ userId: options.signerUserId, signerDisplayName, signatureImageBase64: dto.signatureImageBase64, }); } catch (err) { this.logger.warn( `Could not save reusable signature for user ${options.signerUserId}: ${err}`, ); } } } /** * Send the sudo-mode signing OTP to the SIGNER's own registered phone and * email — the same contacts {@link sign} verifies against. The client never * picks them (that is the H12(b) trust property): it only asks us to send, and * we resolve them from the authenticated user id. Returns a masked hint so the * UI can say where the code went without exposing the full values. */ async sendSigningOtp( contractId: string, options: { signerUserId?: string }, ): Promise<{ sentTo: string }> { const contract = await this.contractsService.findById(contractId); // Same ownership gate as signing — only the owning company's customer may // trigger a code for this contract. await this.contractsService.assertCustomerCanAccessContract( options.signerUserId, contract, ); assertContractStatus(contract, ['CONTRACT_READY']); const signerContacts = await this.resolveSignerContacts(options.signerUserId); await this.otpService.sendOtp(signerContacts); return { sentTo: maskSignerContacts(signerContacts) }; } /** Customer signs the ready contract → SIGNED_CUSTOMER. */ async sign( contractId: string, dto: SignContractDto, options: { signerUserId?: string }, ): Promise { const contract = await this.contractsService.findById(contractId); if (dto.role === 'CUSTOMER') { // H12(a): only the owning company's customer may sign — assert ownership // before anything else (hidden as NotFound otherwise). A signing customer // has no permission key, so this is the gate that binds the sign to the // contract's company. await this.contractsService.assertCustomerCanAccessContract( options.signerUserId, contract, ); assertContractStatus(contract, ['CONTRACT_READY']); const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER'); if (existing) { throw new BadRequestException('Customer has already signed this contract'); } // Sudo-mode gate: a fresh, single-use OTP must be verified before the // signature is applied. H12(b): verify against the SIGNER's own registered // contacts, resolved server-side from the authenticated user id — never // caller-supplied ones, which an attacker could point at their own phone // or mailbox. Ownership is already asserted above, so this proves the // specific person holding the account is present, not merely that someone // reached a shared company line. Must resolve identically to // sendSigningOtp, or send and verify would target different contacts. const signerContacts = await this.resolveSignerContacts(options.signerUserId); if (!dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } await this.otpService.verifyOtpForAction(signerContacts, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', customerSignedAt: new Date(), } as never); await this.regenerateContractPdf(contractId, contract.reference); const updated = await this.contractsService.findById(contractId); this.notifier.customerSignedToStaff(updated); return updated; } return this.counterSign(contractId, dto, options); } /** * Staff/Director/CEO counter-sign → branch on the execution path. A customs * border (IMPORT/EXPORT) always requires a clearance gate before any shipment; * who reviews differs: * - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL * uploads output, then GL creates the booking. * - Path A (no customs): the customer clears the cargo himself and uploads his * own clearance proof; Operations reviews it; then the CUSTOMER books. * DOMESTIC/intercity has no border, so it goes straight to executed. */ async counterSign( contractId: string, dto: SignContractDto, options: { signerUserId?: string }, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['SIGNED_CUSTOMER']); await this.applySignature(contract, dto, options); const now = new Date(); const updates: Record = { fullyExecutedAt: now, lockedAt: now, }; // A clearance gate applies whenever a clearance doc set resolves — Path B // (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the // intercity document set (DOMESTIC, ops-reviewed like Path A). const clearanceCode = contractClearanceSettingCode( contract.tradeDirection, contract.freightType, contract.customsClearingEnabled ?? false, ); // GENERAL contracts run clearance PER BOOKING, not at the contract level — // both paths. Customs (Path B): the customer files shipment requests, GL // books each one and the booking carries its own clearance. Self-clearance // (Path A): the customer books, then uploads the clearance docs on that // booking for Operations to review. Only ONE_TIME contracts keep the // contract-level cycle below. const isGeneral = contract.contractKind === 'GENERAL'; if (clearanceCode && !isGeneral) { // Open a clearance cycle, seed the pre-booking milestones, and route the // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the // distinction is enforced at the review/finalize endpoints, not here. const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); // No prepay gate: the customs clearance service fee (Path B) is billed on // the booking invoice together with the freight, so the document step // opens immediately. updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; } else { // No contract-level clearance gate — DOMESTIC, or any GENERAL contract // (which clears per booking). Ready for shipment requests / direct booking. updates.status = contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; updates.clearanceStatus = 'NOT_APPLICABLE'; } await this.contractsRepository.update(contractId, updates as never); await this.regenerateContractPdf(contractId, contract.reference); const updated = await this.contractsService.findById(contractId); this.notifier.signedActive(updated); return updated; } /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */ async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); // Retry past a concurrent insert that grabbed the same CTR sequence number. const renewal = await insertWithGeneratedReference( () => this.generateRenewalReference(), (reference) => this.contractsRepository.create({ reference, companyId: source.companyId, companyProfileId: source.companyProfileId, isGovernment: source.isGovernment, governmentInstitution: source.governmentInstitution, contractKind: source.contractKind, renewalOfId: source.id, tradeDirection: source.tradeDirection, freightType: source.freightType, serviceTypeId: source.serviceTypeId, paymentCurrency: source.paymentCurrency, customsClearingEnabled: source.customsClearingEnabled, customsClearingAgent: source.customsClearingAgent, equipmentReturn: source.equipmentReturn, firstMilePickupAddress: source.firstMilePickupAddress, firstMilePickupLat: source.firstMilePickupLat, firstMilePickupLng: source.firstMilePickupLng, lastMileDeliveryAddress: source.lastMileDeliveryAddress, lastMileDeliveryLat: source.lastMileDeliveryLat, lastMileDeliveryLng: source.lastMileDeliveryLng, isHazardous: source.isHazardous, isReefer: source.isReefer, contractType: source.contractType, versionNumber: (source.versionNumber ?? 1) + 1, status: 'RENEWAL_DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, } as never), ); void userId; return this.contractsService.findById(renewal.id); } private async generateRenewalReference(): Promise { const year = new Date().getFullYear(); const seq = await this.contractsRepository.maxReferenceSequence(year); return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } }