Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
2026-07-23 13:54:09 +00:00

1232 lines
48 KiB
TypeScript

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<Contract> {
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<Contract> {
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<Contract> {
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<ContractDocumentDraft> {
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<Contract> {
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<ContractDocumentSnapshot | null> {
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<boolean> {
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<string | null> {
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<void> {
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<void> {
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<void> {
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<Contract> {
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<Contract> {
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<Contract> {
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<Contract> {
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<Contract> {
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<string, unknown> = {};
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<Contract> {
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<void> {
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<Contract> {
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<void> {
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<FileRecord> {
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<void> {
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<Buffer> {
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<void> {
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<Contract> {
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<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
await this.applySignature(contract, dto, options);
const now = new Date();
const updates: Record<string, unknown> = {
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<Contract> {
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<string> {
const year = new Date().getFullYear();
const seq = await this.contractsRepository.maxReferenceSequence(year);
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
}