feat(freight-api): seal EDR side of contracts with the one global stamp

This commit is contained in:
ghost2023
2026-08-12 06:26:46 +03:00
parent e52d666647
commit a603807e8e
21 changed files with 679 additions and 183 deletions

View File

@@ -0,0 +1,99 @@
import { ContractViewModelBuilder, ContractSignatureView } from "./contract-view-model.builder";
/**
* The EDR side of a contract is sealed with the ONE global company stamp, read
* live at render time; the client side keeps whatever stamp the customer
* uploaded. These specs pin that asymmetry — the standing rule is that
* centralizing the EDR seal must not touch customer stamps.
*/
describe("ContractViewModelBuilder.attachProviderStamp", () => {
const STAMP = "data:image/png;base64,RURS";
const build = (stampImageUrl: string | null = STAMP) => {
const getStampImageUrl = jest.fn().mockResolvedValue(stampImageUrl);
const builder = Object.create(
ContractViewModelBuilder.prototype,
) as ContractViewModelBuilder;
Object.assign(builder, { stampSettings: { getStampImageUrl } });
return { builder, getStampImageUrl };
};
const sig = (role: "STAFF" | "CUSTOMER", extra: Partial<ContractSignatureView> = {}) =>
({
role,
signerDisplayName: `${role} signer`,
signedAt: "1 January 2026",
signatureImageUrl: "https://minio.local/sig.png",
...extra,
}) as ContractSignatureView;
it("stamps the EDR side with the global stamp", async () => {
const { builder } = build();
const signatures = [sig("STAFF")];
await builder.attachProviderStamp(signatures);
expect(signatures[0]!.stampImageUrl).toBe(STAMP);
});
it("leaves the customer side untouched", async () => {
const { builder } = build();
const customerStamp = "data:image/png;base64,Q1VTVA==";
const signatures = [
sig("CUSTOMER", { stampImageUrl: customerStamp }),
sig("STAFF"),
];
await builder.attachProviderStamp(signatures);
expect(signatures[0]!.stampImageUrl).toBe(customerStamp);
expect(signatures[1]!.stampImageUrl).toBe(STAMP);
});
it("does not read the stamp at all when EDR has not signed yet", async () => {
const { builder, getStampImageUrl } = build();
const signatures = [sig("CUSTOMER")];
await builder.attachProviderStamp(signatures);
expect(getStampImageUrl).not.toHaveBeenCalled();
expect(signatures[0]!.stampImageUrl).toBeUndefined();
});
it("renders unstamped rather than failing when no stamp is configured", async () => {
const { builder } = build(null);
const signatures = [sig("STAFF")];
await expect(builder.attachProviderStamp(signatures)).resolves.toBeUndefined();
expect(signatures[0]!.stampImageUrl).toBeNull();
});
it("reads the stamp once for every EDR signature row", async () => {
const { builder, getStampImageUrl } = build();
const signatures = [sig("STAFF"), sig("STAFF")];
await builder.attachProviderStamp(signatures);
expect(getStampImageUrl).toHaveBeenCalledTimes(1);
expect(signatures.map((s) => s.stampImageUrl)).toEqual([STAMP, STAMP]);
});
it("is applied by loadSignatures, so the HTML view and the PDF agree", async () => {
const { builder } = build();
Object.assign(builder, {
bookingsRepository: {
findContractSignatures: jest.fn().mockResolvedValue([
{ signerRole: "STAFF", signerDisplayName: "EDR", signedAt: new Date() },
]),
},
});
const views = await (
builder as unknown as {
loadSignatures(id: string): Promise<ContractSignatureView[]>;
}
).loadSignatures("b-1");
expect(views[0]!.stampImageUrl).toBe(STAMP);
});
});

View File

@@ -9,6 +9,7 @@ import {
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
export interface ContractSignatureView {
@@ -16,6 +17,11 @@ export interface ContractSignatureView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
/**
* Round company seal shown beside the signature. Populated for the EDR
* (STAFF) side only, from the single global stamp — see attachProviderStamp.
*/
stampImageUrl?: string | null;
}
/**
@@ -114,6 +120,7 @@ export class ContractViewModelBuilder {
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
private readonly stampSettings: StampSettingsService,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
@@ -194,7 +201,30 @@ export class ContractViewModelBuilder {
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
return rows.map((s) => this.toSignatureView(s));
const views = rows.map((s) => this.toSignatureView(s));
await this.attachProviderStamp(views);
return views;
}
/**
* Stamp the EDR side of the contract with the ONE global company stamp
* (StampSettingsService) — staff never upload or pick a stamp, so nothing is
* stored per signature and the seal is read live at render time. The client
* side is left alone: a customer's own stamp is their business.
*
* Read live and deliberately not snapshotted, so replacing the company stamp
* re-seals contracts on their next render. `getStampImageUrl()` never throws
* and returns a data URL, which `signatures_block.hbs` renders as-is and the
* signature inliner skips.
*/
async attachProviderStamp(signatures: ContractSignatureView[]): Promise<void> {
const staff = signatures.filter((s) => s.role === 'STAFF');
if (staff.length === 0) return;
const stampImageUrl = await this.stampSettings.getStampImageUrl();
for (const sig of staff) {
sig.stampImageUrl = stampImageUrl;
}
}
toSignatureView(row: BookingContractSignature): ContractSignatureView {

View File

@@ -15,10 +15,13 @@ import { getAuditLoggerConfig } from "@tria-plc/auditlog";
import { AppModule } from "./app.module";
/**
* JSON body ceiling. Signing posts the signature AND the company stamp as
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 50MB asset is
* ~67MB on the wire. Express defaults to 100kb, which rejected any real stamp
* image with a 413 "request entity too large".
* JSON body ceiling. Customer signing posts the signature AND the customer's
* own company stamp as base64 in one JSON body, and base64 inflates bytes by
* ~4/3 — a 50MB asset is ~67MB on the wire. Express defaults to 100kb, which
* rejected any real stamp image with a 413 "request entity too large".
* (Staff signing posts only a signature: EDR's seal is the one global stamp,
* read server-side. Uploading that stamp under Settings goes through this same
* ceiling, so the headroom is still needed on both counts.)
*
* Sized to clear the 50MB per-document ceiling
* (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the

View File

@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
import { PdfRenderService } from "./pdf-render.service";
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
import {
PdfColor,
assembleSinglePagePdf,
@@ -239,10 +240,8 @@ export class InvoiceDocumentService {
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const sealMarkup = model.stampImageUrl
? `<img src="${esc(model.stampImageUrl)}" alt="Company stamp" />`
: esc(sealText);
const sealClass = model.stampImageUrl ? "seal seal-image" : "seal";
const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl);
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
@@ -281,8 +280,7 @@ export class InvoiceDocumentService {
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.seal.seal-image { border: none; border-radius: 0; opacity: 1; transform: none; }
.seal img { max-width: 100%; max-height: 100%; object-fit: contain; }
${sealImageCss()}
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
@@ -310,7 +308,7 @@ export class InvoiceDocumentService {
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="${sealClass}">${sealMarkup}</div>
<div class="${sealCssClass}">${sealInner}</div>
<div class="summary">${summaryRows}</div>
<table>
<thead>

View File

@@ -0,0 +1,78 @@
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
/**
* These three helpers are the single image-vs-text branch shared by every
* EDR document's round seal, so a regression here silently unstamps invoices,
* warehouse release papers and handover papers at once.
*/
describe("seal markup helpers", () => {
const STAMP = "data:image/png;base64,QUJD";
describe("sealMarkup", () => {
it("renders the stamp image when one is configured", () => {
expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe(
`<img src="${STAMP}" alt="Company stamp" />`,
);
});
it("falls back to text rings when no stamp is configured", () => {
expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe(
"<span>EDR<br />Warehouse<br />Cleared</span>",
);
});
it("treats undefined as unset", () => {
expect(sealMarkup(undefined, "EDR")).toBe("<span>EDR</span>");
});
it("accepts a bare string as a single line", () => {
expect(sealMarkup(null, "EDR")).toBe("<span>EDR</span>");
});
it("escapes text lines so document data cannot inject markup", () => {
expect(sealMarkup(null, ['<script>alert("x")</script>'])).toBe(
"<span>&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;</span>",
);
});
it("escapes the image src so it cannot break out of the attribute", () => {
expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe(
'<img src="data:image/png;base64,A&quot; onerror=&quot;x" alt="Company stamp" />',
);
});
});
describe("sealClass", () => {
it("adds the image modifier only when stamped", () => {
expect(sealClass(STAMP)).toBe("seal seal-image");
expect(sealClass(null)).toBe("seal");
});
it("honours a document's own seal selector", () => {
expect(sealClass(STAMP, "sig-stamp-box")).toBe(
"sig-stamp-box sig-stamp-box-image",
);
expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box");
});
});
describe("sealImageCss", () => {
it("neutralizes the drawn ring and rotation for a real stamp image", () => {
const css = sealImageCss();
expect(css).toContain(".seal.seal-image { border: none;");
expect(css).toContain("transform: none;");
// The ::before pseudo-element draws the inner ring of the text seal.
expect(css).toContain(".seal.seal-image::before { content: none; }");
expect(css).toContain(".seal img { max-width: 100%;");
});
it("scopes every rule to the given selector", () => {
const css = sealImageCss("sig-stamp-box");
expect(css).not.toContain(".seal");
expect(css).toContain(".sig-stamp-box.sig-stamp-box-image");
expect(css).toContain(".sig-stamp-box img");
});
});
});

View File

@@ -0,0 +1,64 @@
/**
* The single decision every EDR document makes about its round seal: draw the
* one uploaded company stamp when one is configured (StampSettingsService), or
* fall back to the plain text rings the document styles itself.
*
* Only the image-vs-text branch and the image overrides live here — each
* document keeps its own `.seal` geometry (the invoice's seal is absolutely
* positioned top-right, the warehouse papers' sit inline above the signature
* lines), so centralizing the source of the stamp does not relayout anything.
*
* These helpers are for the HTML/Chromium render path. The hand-built vector
* fallbacks in styled-pdf.util.ts cannot embed a raster image and continue to
* draw their vector seal — see InvoiceDocumentService for that caveat.
*/
/** Escape a value for interpolation into HTML text or a quoted attribute. */
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* CSS overrides that neutralize a document's own ring/rotation styling when the
* seal is a real stamp image. Append inside a document's <style> block, after
* its own `.seal` rules. `selector` is the document's seal class ("seal").
*/
export function sealImageCss(selector = "seal"): string {
return [
`.${selector}.${selector}-image { border: none; border-radius: 0; opacity: 1; transform: none; }`,
`.${selector}.${selector}-image::before { content: none; }`,
`.${selector} img { max-width: 100%; max-height: 100%; object-fit: contain; }`,
].join("\n ");
}
/**
* Inner markup for the seal element: the stamp image, or the given text lines
* wrapped in a <span> (matching the `.seal span { position: relative }` rule
* the ring-drawing documents rely on).
*
* `stampImageUrl` is expected to be a data URL from
* StampSettingsService.getStampImageUrl(); null renders the text fallback.
*/
export function sealMarkup(
stampImageUrl: string | null | undefined,
textLines: string | string[],
): string {
if (stampImageUrl) {
return `<img src="${escapeHtml(stampImageUrl)}" alt="Company stamp" />`;
}
const lines = Array.isArray(textLines) ? textLines : [textLines];
return `<span>${lines.map(escapeHtml).join("<br />")}</span>`;
}
/** Class attribute for the seal element — adds the image modifier when stamped. */
export function sealClass(
stampImageUrl: string | null | undefined,
selector = "seal",
): string {
return stampImageUrl ? `${selector} ${selector}-image` : selector;
}

View File

@@ -304,6 +304,7 @@ export class BookingContractService {
async getSignatures(bookingId: string) {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
await this.viewModelBuilder.attachProviderStamp(views);
await this.inlineSignatureImages(views);
return { signatures: views };
}

View File

@@ -12,6 +12,12 @@ export class ContractSignatureDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
@ApiPropertyOptional({
description:
'Company seal beside the signature. Set for the EDR (STAFF) side from the single global company stamp; null for the client side.',
})
stampImageUrl?: string | null;
}
export class SavedSignatureViewDto {

View File

@@ -138,6 +138,13 @@ export class CompanyIdentityStateDto {
})
etradeManagerName!: string | null;
@ApiProperty({
nullable: true,
description:
"That manager's phone, normalized to E.164 and captured at the same lookup. Paired with the name so the portal can still tell that the owner's details came from eTrade after a refresh, when the live lookup result is long gone — without it a resumed wizard offers them back as typeable inputs.",
})
etradeManagerPhone!: string | null;
@ApiProperty({
nullable: true,
description:
@@ -277,6 +284,8 @@ export function buildCompanyIdentityState(
const etradeManagerName =
(attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null;
const etradeManagerPhone =
(attrs[ETRADE_MANAGER_PHONE_KEY] as string | undefined) ?? null;
return {
passportAccepted,
@@ -286,6 +295,7 @@ export function buildCompanyIdentityState(
poa,
identityProven,
etradeManagerName,
etradeManagerPhone,
ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName),
complete: subject !== null && identityProven,
};

View File

@@ -0,0 +1,152 @@
import { BadRequestException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Where the booking-contract view reads the global stamp live, the contracts
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
* company stamp can never restamp an already-executed contract. These specs
* pin the sourcing split: EDR always seals with the global stamp and staff
* never supply one, while the customer must upload their own.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
saved.push(row);
return Promise.resolve(undefined);
}),
},
signaturesService: {
getForUser: jest.fn().mockResolvedValue(null),
upsertForUser: jest.fn().mockResolvedValue(undefined),
},
uploadSignatureAsset: jest.fn((_c: unknown, code: string, image: string) => {
uploads.push({ code, image });
return Promise.resolve({ id: `file-${code}` });
}),
});
return { service, uploads, saved };
};
const apply = (
service: ContractTransitionService,
dto: Record<string, unknown>,
) =>
(
service as unknown as {
applySignature(
c: unknown,
d: unknown,
o: { signerUserId?: string },
): Promise<void>;
}
).applySignature(contract, dto, { signerUserId: 'u-1' });
const staffDto = {
role: 'STAFF' as const,
signerDisplayName: 'E. Staff',
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = build();
await apply(service, {
...staffDto,
stampImageBase64: 'data:image/png;base64,SEFDSw==',
});
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.image)).not.toContain(
'data:image/png;base64,SEFDSw==',
);
});
/**
* Failing loudly matters here: getStampImageUrl degrades to null when the
* stamp cannot be inlined, and silently executing an unsealed contract would
* be worse than refusing to counter-sign.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);
await expect(apply(service, staffDto)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(apply(service, staffDto)).rejects.toThrow(/company stamp is configured/i);
expect(saved).toHaveLength(0);
});
it('requires the customer to upload their own stamp', async () => {
const { service, saved } = build();
await expect(
apply(service, {
role: 'CUSTOMER',
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,U0lH',
}),
).rejects.toThrow(/company stamp is required/i);
expect(saved).toHaveLength(0);
});
it('snapshots the customer stamp and never substitutes the global one', async () => {
const { service, uploads } = build();
const customerStamp = 'data:image/png;base64,Q1VTVA==';
await apply(service, {
role: 'CUSTOMER',
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,U0lH',
stampImageBase64: customerStamp,
});
expect(uploads).toContainEqual({
code: 'stamp_customer',
image: customerStamp,
});
expect(uploads.map((u) => u.image)).not.toContain(GLOBAL_STAMP);
});
/**
* DIRECTOR/CEO rows are internal approval signatures, not party seals, so
* they are deliberately exempt from the stamp requirement.
*/
it('lets internal approval signatures through without any stamp', async () => {
const { service, uploads, saved } = build();
await apply(service, {
role: 'DIRECTOR',
signerDisplayName: 'D. Director',
signatureImageBase64: 'data:image/png;base64,U0lH',
});
expect(uploads.map((u) => u.code)).toEqual(['signature_director']);
expect(saved[0]).toEqual(expect.objectContaining({ stampFileId: null }));
});
});

View File

@@ -35,6 +35,7 @@ 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 { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
@@ -175,6 +176,7 @@ export class ContractTransitionService {
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly stampSettings: StampSettingsService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -1103,23 +1105,39 @@ export class ContractTransitionService {
// The company stamp is a separate image from the drawn signature. Both
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
// are internal approval signatures, not party seals, so they stay exempt.
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
if (stampRequired && !dto.stampImageBase64) {
//
// The two parties source their seal differently: the customer uploads their
// own company stamp, while EDR always seals with the ONE global stamp
// (StampSettingsService) — staff never upload or pick a stamp.
if (role === 'CUSTOMER' && !dto.stampImageBase64) {
throw new BadRequestException(
'A company stamp is required to sign this contract.',
);
}
// Snapshot whichever stamp applies onto the signature row rather than
// referencing the global one, so replacing the company stamp later can
// never restamp an already-executed contract.
let stampImageBase64 = dto.stampImageBase64 ?? null;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
}
const fileRecord = await this.uploadSignatureAsset(
contract,
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = dto.stampImageBase64
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
stampImageBase64,
)
: null;

View File

@@ -0,0 +1,110 @@
import { Readable } from "stream";
import { StampSettingsService } from "./stamp-settings.service";
/**
* The one global company stamp feeds three document paths (invoices, warehouse
* papers, contract signature blocks). All three treat the returned value as a
* `data:` URL — so the data-URL-or-null contract of getStampImageUrl is what
* these specs pin down, especially its behaviour when MinIO cannot be reached.
*/
describe("StampSettingsService.getStampImageUrl", () => {
const PNG = Buffer.from("fake-png-bytes");
const OBJECT_URL = "https://minio.local:9000/edr-freight/stamp/company.png";
const build = (
overrides: {
stampUrl?: string | null;
getFileStream?: jest.Mock;
findSingleton?: jest.Mock;
} = {},
) => {
const service = Object.create(
StampSettingsService.prototype,
) as StampSettingsService;
const warn = jest.fn();
Object.assign(service, {
logger: { warn, log: jest.fn() },
repository: {
findSingleton:
overrides.findSingleton ??
jest.fn().mockResolvedValue({
id: "s-1",
stampFileId: overrides.stampUrl ? "f-1" : null,
stampFile: overrides.stampUrl ? { url: overrides.stampUrl } : null,
updatedById: null,
updatedAt: null,
}),
create: jest.fn(),
update: jest.fn(),
},
minioService: {
getObjectNameFromUrl: jest.fn().mockReturnValue("stamp/company.png"),
getFileStream:
overrides.getFileStream ??
jest.fn().mockResolvedValue(Readable.from(PNG)),
},
filesService: { upload: jest.fn() },
});
return { service, warn };
};
it("inlines the stored stamp as a data URL", async () => {
const { service } = build({ stampUrl: OBJECT_URL });
await expect(service.getStampImageUrl()).resolves.toBe(
`data:image/png;base64,${PNG.toString("base64")}`,
);
});
it("returns null when no stamp is configured", async () => {
const { service } = build({ stampUrl: null });
await expect(service.getStampImageUrl()).resolves.toBeNull();
});
it("passes an already-inlined data URL straight through", async () => {
const dataUrl = "data:image/png;base64,QUJD";
const { service } = build({ stampUrl: dataUrl });
await expect(service.getStampImageUrl()).resolves.toBe(dataUrl);
});
/**
* Regression: inlineImageUrl falls back to the raw object URL when MinIO is
* unreachable, which is right for getView (a browser can fetch it) but wrong
* here. ContractTransitionService base64-decodes this value to snapshot the
* seal — and a URL decodes to garbage bytes WITHOUT throwing, so a transient
* MinIO failure used to seal an executed contract with a corrupt image file.
* Degrade to null instead so callers draw their text/vector seal.
*/
it("returns null rather than a raw object URL when MinIO inlining fails", async () => {
const { service, warn } = build({
stampUrl: OBJECT_URL,
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
});
await expect(service.getStampImageUrl()).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/plain seal/i));
});
it("never throws when the settings lookup itself fails", async () => {
const { service, warn } = build({
findSingleton: jest.fn().mockRejectedValue(new Error("db is down")),
});
await expect(service.getStampImageUrl()).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("db is down"));
});
it("still exposes the raw URL through getView, which a browser can load", async () => {
const { service } = build({
stampUrl: OBJECT_URL,
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
});
await expect(service.getView()).resolves.toEqual(
expect.objectContaining({ stampImageUrl: OBJECT_URL }),
);
});
});

View File

@@ -49,14 +49,30 @@ export class StampSettingsService {
}
/**
* The stamp image for embedding into invoice PDFs. Never throws — invoice
* generation must succeed even if the stamp lookup fails; callers fall back
* to the programmatic seal when this returns null.
* The stamp image for embedding into generated documents, ALWAYS as a
* `data:` URL or null. Never throws — document generation must succeed even
* if the stamp lookup fails; callers fall back to their own seal on null.
*
* The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do
* two things with this value that a bare MinIO URL silently corrupts:
* ContractTransitionService base64-decodes it to snapshot the seal onto a
* signature row (a URL decodes to garbage bytes, not an error, permanently
* sealing an executed contract with a broken image), and the HTML render
* path inlines it into an <img> that headless Chromium cannot fetch. So
* where getView() may hand a raw URL to a browser that can load it, this
* degrades to null and lets the caller draw its text/vector seal instead.
*/
async getStampImageUrl(): Promise<string | null> {
try {
const setting = await this.get();
return await this.inlineImageUrl(setting.stampFile?.url);
const inlined = await this.inlineImageUrl(setting.stampFile?.url);
if (inlined && !inlined.startsWith("data:")) {
this.logger.warn(
`Company stamp could not be inlined for document rendering (falling back to the plain seal): ${inlined}`,
);
return null;
}
return inlined;
} catch (err) {
this.logger.warn(
`Could not load company stamp for PDF rendering: ${(err as Error).message}`,

View File

@@ -34,6 +34,8 @@ import {
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -414,6 +416,7 @@ export class WarehouseInventoryService {
private readonly handover: HandoverService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
private readonly stampSettings: StampSettingsService,
) {}
/**
@@ -3744,6 +3747,7 @@ export class WarehouseInventoryService {
const html = this.buildReleaseDocumentHtml({
reference,
issuedAt,
stampImageUrl: await this.stampSettings.getStampImageUrl(),
bookingReference,
bookingStatus: row?.bookingStatus ?? null,
customerName: row?.customerName ?? null,
@@ -4798,6 +4802,7 @@ export class WarehouseInventoryService {
const html = this.buildHandoverDocumentHtml({
reference,
handedOverAt,
stampImageUrl: await this.stampSettings.getStampImageUrl(),
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
@@ -5603,6 +5608,8 @@ export class WarehouseInventoryService {
truckType?: string | null;
truckGateOut?: string | null;
truckWeightTons?: number | null;
/** The one global company stamp; null falls back to the drawn text seal. */
stampImageUrl?: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -5689,6 +5696,7 @@ export class WarehouseInventoryService {
.seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
${sealImageCss()}
</style>
</head>
<body>
@@ -5722,7 +5730,7 @@ export class WarehouseInventoryService {
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Cleared</span></div>
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Cleared'])}</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</div>
@@ -5762,6 +5770,8 @@ export class WarehouseInventoryService {
signerDisplayName: string;
signatureImageUrl: string | null;
} | null;
/** The one global company stamp; null falls back to the drawn text seal. */
stampImageUrl?: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -5839,6 +5849,7 @@ export class WarehouseInventoryService {
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
${sealImageCss()}
</style>
</head>
<body>
@@ -5882,7 +5893,7 @@ export class WarehouseInventoryService {
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Handover'])}</div>
<div class="line">
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>

View File

@@ -1301,7 +1301,10 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
"Edit contract templates & articles",
),
// Granular split of contract-template access. `view` opens the sidebar page;
// `read` is API-read-only for other pages that display template data.
// `read` is API-read-only for other pages that display template data — and is
// NOT written out here: deriveReadPermissions mints the `:read` twin of every
// `:view` key, so a hand-written one duplicates the key (Postgres 21000 on the
// seeder's ON CONFLICT (key) insert) and carries a v4 id where twins are v5.
perm(
"b4e00001-0001-4000-8000-000000000003",
"edr_freight_app:settings:contract_templates:create",
@@ -1317,11 +1320,6 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:contract_templates:delete",
"Delete bulk contract templates",
),
perm(
"b4e00001-0001-4000-8000-000000000006",
"edr_freight_app:settings:contract_templates:read",
"Read contract template data (API only)",
),
perm(
"b4f00001-0001-4000-8000-000000000001",
"edr_freight_app:settings:support_content:view",