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 {