diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts
index 92958b364..3e086101c 100644
--- a/apps/edr-freight-api/src/common/booking-guards.ts
+++ b/apps/edr-freight-api/src/common/booking-guards.ts
@@ -23,6 +23,14 @@ export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
+/**
+ * The document-review countdown in the backoffice header. Its own permission so
+ * it can be granted to exactly the position types that decide operation
+ * requests, instead of every holder of bookings:view.
+ */
+export const BookingDocReviewAlert = () =>
+ BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
+
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
diff --git a/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts
new file mode 100644
index 000000000..4658a5434
--- /dev/null
+++ b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts
@@ -0,0 +1,47 @@
+import { ForbiddenException } from '@nestjs/common';
+
+import {
+ assertCanApproveContractStep,
+ canEditContractStep,
+} from './freight-permission.util';
+import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
+
+const userWith = (...keys: string[]) => ({
+ permissions: keys.map((key) => ({ key })),
+});
+
+describe('hazardous contract approval steps', () => {
+ it('rejects an approver who only holds ordinary contract-approve permissions', () => {
+ // The blanket "any contract approve permission" fallback must NOT reach
+ // dangerous goods — that is the whole point of the dedicated desks.
+ const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff);
+
+ expect(() =>
+ assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'),
+ ).toThrow(ForbiddenException);
+ expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false);
+ });
+
+ it('accepts only the matching hazardous permission', () => {
+ const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne);
+
+ expect(() =>
+ assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'),
+ ).not.toThrow();
+ // Holding step one does not confer step two.
+ expect(() =>
+ assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'),
+ ).toThrow(ForbiddenException);
+ });
+
+ it('does not let a hazardous approver stand in for the commercial chain', () => {
+ const hazardOnly = userWith(
+ FREIGHT_PERMS.contracts.hazardousApprovalOne,
+ FREIGHT_PERMS.contracts.hazardousApprovalTwo,
+ );
+
+ expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow(
+ ForbiddenException,
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts
index 429c910d3..56c0e77c2 100644
--- a/apps/edr-freight-api/src/common/freight-permission.util.ts
+++ b/apps/edr-freight-api/src/common/freight-permission.util.ts
@@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record = {
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
+/**
+ * Approval-chain roles synthesized for hazardous contracts (see
+ * `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT
+ * position types — they authorize purely on their own dedicated permission, and
+ * they deliberately opt out of the blanket "holds any contract-approve
+ * permission" fallback so a normal approver cannot sign off dangerous goods.
+ */
+export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record = {
+ HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
+ HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
+};
+
+/** The two hazardous steps, in the order they are prepended to the chain. */
+export const HAZARDOUS_APPROVAL_ROLES = [
+ 'HAZARDOUS_APPROVAL_ONE',
+ 'HAZARDOUS_APPROVAL_TWO',
+] as const;
+
const CONTRACT_APPROVE_ROLE_PERMISSION: Record = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
): void {
if (isFreightApprovalAdmin(user)) return;
+ // Hazardous steps are permission-only and strict — no legacy alias, no
+ // blanket approve fallback.
+ const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
+ if (hazardousPermission) {
+ if (hasFreightPermission(user, hazardousPermission)) return;
+ throw new ForbiddenException(
+ `Missing permission: ${hazardousPermission}`,
+ );
+ }
+
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return;
@@ -219,6 +247,11 @@ export function canEditContractStep(
): boolean {
if (isFreightApprovalAdmin(user)) return true;
+ const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
+ if (hazardousPermission) {
+ return hasFreightPermission(user, hazardousPermission);
+ }
+
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;
diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
index 363008660..eb9541d8e 100644
--- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
+import { hazardClassLabel } from '@edr/types';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
import {
@@ -30,6 +31,8 @@ export interface ContractDocumentSignatureView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
+ /** Company stamp/seal; rendered next to the signature when present. */
+ stampImageUrl?: string | null;
}
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
@@ -141,6 +144,11 @@ export class ContractDocumentViewModelBuilder {
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
+ // Signed before company stamps were required — the customer has to sign
+ // again to attach one, otherwise EDR can never counter-sign the contract.
+ const customerStampMissing = signatures.some(
+ (s) => s.role === 'CUSTOMER' && !s.stampImageUrl,
+ );
const hasContractFile = Boolean(
contract.files?.some((f) => f.code === 'contract'),
);
@@ -183,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
// view-model's narrower CUSTOMER|STAFF role union.
signatures: signatures as unknown as ContractViewModel['signatures'],
- canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
+ canSignCustomer:
+ (contract.status === 'CONTRACT_READY' && !hasCustomer) ||
+ (contract.status === 'SIGNED_CUSTOMER' && customerStampMissing),
canSignStaff:
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
@@ -208,6 +218,7 @@ export class ContractDocumentViewModelBuilder {
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
+ stampImageUrl: row.stampFile?.url ?? null,
};
}
@@ -292,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
- hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
+ // A hazardous contract names the declared class + UN number on the
+ // schedule — the flag alone is not a dangerous-goods declaration.
+ hazardousLabel: contract.isHazardous
+ ? [
+ hazardClassLabel(contract.hazardClass) ?? 'Yes',
+ contract.unNumber ? `UN ${contract.unNumber}` : null,
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ : 'No',
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
};
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
index 05dd450e4..59cfceb0b 100644
--- a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
@@ -11,6 +11,12 @@
Name: {{signerDisplayName}}
Role: Authorized EDR representative
Date: {{signedAt}}
+ {{#if stampImageUrl}}
+
+ {{/if}}
{{/if}}
{{/each}}
{{else}}
@@ -32,6 +38,12 @@
Name: {{signerDisplayName}}
Role: Authorized client representative
Date: {{signedAt}}
+ {{#if stampImageUrl}}
+
+ {{/if}}
{{/if}}
{{/each}}
{{else}}
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
index 118606065..d9bc9927f 100644
--- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
@@ -372,25 +372,30 @@
font-size: 9pt;
margin: 4px 0;
}
-
- /* ── Witnesses ────────────────────────────────────────────────────────── */
- .witnesses { margin-top: 20px; }
- .witness-table {
- font-size: 9.5pt;
- margin-top: 6px;
+ .sig-stamp {
+ margin-top: 12px;
}
- .witness-table th,
- .witness-table td {
- border-bottom: 1px solid #c9e4d9;
- padding: 9px 8px;
- text-align: left;
- }
- .witness-table th {
+ .sig-stamp-label {
color: #0e5b45;
font-family: Arial, sans-serif;
- font-size: 8.5pt;
+ font-size: 7.5pt;
+ font-weight: 700;
+ letter-spacing: 0.4pt;
text-transform: uppercase;
}
+ .sig-stamp-box {
+ align-items: center;
+ display: flex;
+ height: 30mm;
+ justify-content: center;
+ margin-top: 5px;
+ }
+ .sig-stamp-box img {
+ display: block;
+ max-height: 30mm;
+ max-width: 45mm;
+ mix-blend-mode: multiply;
+ }
@media print {
body { background: #fff; }
diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
index 0e06d9f7b..9c4957b2b 100644
--- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
@@ -147,19 +147,6 @@
authorized to sign and execute this Contract Agreement.
{{> signatures_block}}
-
-
-
Witnesses
-
-
- | Name | Signature | Date |
-
-
- | 1. | | | |
- | 2. | | | |
-
-
-