mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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><script>alert("x")</script></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" onerror="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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { FileSignature, Loader2, Stamp } from "lucide-react";
|
||||
import { FileSignature, Loader2 } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -27,9 +26,13 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in user view and update the reusable signature and company
|
||||
* stamp stored on their profile — managed independently of each other. Both
|
||||
* are offered when signing a booking contract.
|
||||
* Lets the signed-in user view and update the reusable signature stored on
|
||||
* their profile, offered for approval when signing a contract.
|
||||
*
|
||||
* Signature only — there is no per-employee stamp. EDR seals with ONE global
|
||||
* company stamp, managed under Settings and applied server-side, so a staff
|
||||
* member never uploads or picks a stamp. (Customers do upload their own, in
|
||||
* the portal — that is a different card.)
|
||||
*/
|
||||
export function MySignatureCard() {
|
||||
const { user } = useAuth();
|
||||
@@ -39,10 +42,8 @@ export function MySignatureCard() {
|
||||
const saveMutation = useMutation(api.signatures.save.mutationOptions());
|
||||
|
||||
const [signatureOpen, setSignatureOpen] = useState(false);
|
||||
const [stampOpen, setStampOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
|
||||
const defaultName =
|
||||
user?.name?.en || user?.username || user?.email || "";
|
||||
@@ -60,7 +61,6 @@ export function MySignatureCard() {
|
||||
{
|
||||
signerDisplayName: signerName.trim(),
|
||||
signatureImageBase64: signatureData,
|
||||
// Stamp untouched — it is managed by its own dialog.
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -72,38 +72,16 @@ export function MySignatureCard() {
|
||||
);
|
||||
};
|
||||
|
||||
const openStampDialog = () => {
|
||||
setStampData(saved?.stampImageUrl ?? null);
|
||||
setStampOpen(true);
|
||||
};
|
||||
|
||||
const saveStamp = () => {
|
||||
if (!stampData) return;
|
||||
saveMutation.mutate(
|
||||
{
|
||||
signerDisplayName: savedName || defaultName,
|
||||
// Signature untouched — stamp-only update.
|
||||
stampImageBase64: stampData,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Stamp saved");
|
||||
setStampOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Failed to save stamp"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-4" />
|
||||
Signature & Stamp
|
||||
Signature
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This signature can be reused to sign booking contracts.
|
||||
This signature can be reused to sign booking contracts. The EDR
|
||||
company stamp is applied automatically — you do not upload one.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
@@ -112,54 +90,29 @@ export function MySignatureCard() {
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{saved?.signatureImageUrl ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={saved.signatureImageUrl}
|
||||
alt="My saved signature"
|
||||
className="mx-auto h-36 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved as {saved.signerDisplayName}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not saved a signature yet.
|
||||
<div className="space-y-2">
|
||||
{saved?.signatureImageUrl ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={saved.signatureImageUrl}
|
||||
alt="My saved signature"
|
||||
className="mx-auto h-36 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved as {saved.signerDisplayName}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
|
||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{saved?.stampImageUrl ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={saved.stampImageUrl}
|
||||
alt="My saved company stamp"
|
||||
className="mx-auto h-24 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Company stamp</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not uploaded a company stamp yet.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={openStampDialog}>
|
||||
<Stamp className="size-4" />
|
||||
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not saved a signature yet.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
|
||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -203,39 +156,6 @@ export function MySignatureCard() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Company stamp</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload your official company stamp or seal as an image. It is
|
||||
stored on your profile and applied next to your signature on
|
||||
contracts.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description="Stored on your profile and prefilled when you sign contracts."
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setStampOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saveMutation.isPending || !stampData}
|
||||
onClick={saveStamp}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Save stamp"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import toast from "react-hot-toast";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
||||
@@ -41,9 +40,6 @@ export default function BookingContractPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Company stamp: prefilled from the profile, or uploaded here when none is
|
||||
// saved yet.
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
// When the user has a saved signature we offer it for approval first; they
|
||||
// can switch to drawing a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
@@ -59,7 +55,6 @@ export default function BookingContractPage() {
|
||||
|
||||
const savedSignature = data?.savedSignature ?? null;
|
||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||
const savedStampImage = savedSignature?.stampImageUrl ?? null;
|
||||
// Show the approval view only while a saved signature exists and the user
|
||||
// hasn't opted to draw a new one.
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
@@ -103,8 +98,6 @@ export default function BookingContractPage() {
|
||||
// approve it; otherwise start with an empty pad.
|
||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
// Prefill with the reusable stamp saved on the profile; still replaceable.
|
||||
setStampData(savedStampImage);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
@@ -113,12 +106,12 @@ export default function BookingContractPage() {
|
||||
if (!canSign || !signerName.trim()) return;
|
||||
// Approve the saved signature, or submit the freshly drawn one.
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
// The API rejects a STAFF signature without a stamp.
|
||||
if (!image || !stampData) return;
|
||||
if (!image) return;
|
||||
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
|
||||
// server-side at render time (see StampSettingsService).
|
||||
signMutation.mutate({
|
||||
role: "STAFF",
|
||||
signatureImageBase64: image,
|
||||
stampImageBase64: stampData,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
});
|
||||
@@ -243,15 +236,6 @@ export default function BookingContractPage() {
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description={
|
||||
savedStampImage
|
||||
? "Your saved company stamp — replace it for this contract if needed."
|
||||
: "Required. Attach your official company stamp or seal."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||
@@ -261,7 +245,6 @@ export default function BookingContractPage() {
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData ||
|
||||
!signerName.trim()
|
||||
}
|
||||
onClick={confirmSign}
|
||||
|
||||
@@ -18,7 +18,6 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
@@ -40,7 +39,6 @@ export default function ContractViewPage() {
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
@@ -56,10 +54,11 @@ export default function ContractViewPage() {
|
||||
mutationFn: () =>
|
||||
contractsService.signContract(id!, {
|
||||
role: "STAFF",
|
||||
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
|
||||
// server-side from StampSettingsService when the signature is stored.
|
||||
signatureImageBase64: usingSaved
|
||||
? (savedSignatureImage as string)
|
||||
: (signatureData ?? ""),
|
||||
stampImageBase64: stampData ?? "",
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I confirm this contract on behalf of EDR.",
|
||||
}),
|
||||
@@ -98,14 +97,12 @@ export default function ContractViewPage() {
|
||||
const openSign = () => {
|
||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
// Prefill with the reusable stamp saved on the profile; still replaceable.
|
||||
setStampData(data?.savedSignature?.stampImageUrl ?? null);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signerName.trim() || !stampData) return;
|
||||
if (!signerName.trim()) return;
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
signMutation.mutate();
|
||||
@@ -243,13 +240,6 @@ export default function ContractViewPage() {
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
label="EDR company stamp"
|
||||
description="Attach the official EDR stamp or seal — it is applied to the contract next to the signature."
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setSignOpen(false)}>
|
||||
Cancel
|
||||
@@ -260,8 +250,7 @@ export default function ContractViewPage() {
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData
|
||||
(!usingSaved && !signatureData)
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
|
||||
@@ -111,11 +111,14 @@ export interface ConsolidationDetails {
|
||||
splitBilling: { bookingShare: number; partnerShare: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* No stamp field: the backoffice only ever signs as STAFF, and EDR's seal is
|
||||
* the ONE global company stamp applied server-side. Customer stamps are posted
|
||||
* from the portal, not here.
|
||||
*/
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
|
||||
stampImageBase64?: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
@@ -118,11 +118,14 @@ export interface ContractView {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* No stamp field: the backoffice only ever counter-signs as STAFF, and EDR's
|
||||
* seal is the ONE global company stamp, snapshotted server-side from
|
||||
* StampSettingsService when the signature is stored.
|
||||
*/
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
/** Company stamp/seal image; required to sign a contract. */
|
||||
stampImageBase64?: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@ export interface SavedSignature {
|
||||
stampImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature only. A backoffice employee has no personal stamp — EDR seals with
|
||||
* the one global company stamp managed under Settings — so the stamp half of
|
||||
* PUT /me/signature is deliberately not exposed here, even though the shared
|
||||
* endpoint still accepts it for portal (customer) users.
|
||||
*/
|
||||
export interface SaveSignaturePayload {
|
||||
signerDisplayName: string;
|
||||
/** Omit to keep the existing saved signature (stamp-only update). */
|
||||
/** Omit to keep the existing saved signature. */
|
||||
signatureImageBase64?: string;
|
||||
/** Omit to keep the existing saved stamp. */
|
||||
stampImageBase64?: string;
|
||||
}
|
||||
|
||||
export const signaturesService = {
|
||||
|
||||
Reference in New Issue
Block a user