mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
feat(freight-api): seal EDR side of contracts with the one global stamp
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user