diff --git a/app/components/agreements/SendAgreementModal.test.tsx b/app/components/agreements/SendAgreementModal.test.tsx
new file mode 100644
index 00000000..1a3e8fad
--- /dev/null
+++ b/app/components/agreements/SendAgreementModal.test.tsx
@@ -0,0 +1,32 @@
+// @vitest-environment happy-dom
+/**
+ * DELIV-01 — the API now refuses to send an agreement that still carries
+ * placeholders. That refusal used to land in a page-level line underneath the
+ * modal's scrim, so Send looked like it did nothing. It must read inside the
+ * dialog the inspector is looking at.
+ */
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+import { SendAgreementModal } from "./SendAgreementModal";
+
+const base = {
+ open: true,
+ onSend: () => {},
+ onClose: () => {},
+ initialSigners: [{ name: "Pat Rivera", email: "pat@example.com", role: "client" as const }],
+};
+
+describe("SendAgreementModal serverError", () => {
+ it("shows the API's refusal as an alert inside the dialog", () => {
+ render(
{error}
} + {(error ?? serverError) &&{error ?? serverError}
} ); } diff --git a/app/routes/inspector-portal.tsx b/app/routes/inspector-portal.tsx index 81ee9e89..344a51ca 100644 --- a/app/routes/inspector-portal.tsx +++ b/app/routes/inspector-portal.tsx @@ -1274,12 +1274,10 @@ export default function InspectionHubPage() { : undefined } busy={agreementModal.busy} + serverError={agreementModal.error} onSend={submitSendAgreement} onClose={() => agreementModal.setOpen(false)} /> - {agreementModal.error && ( -{agreementModal.error}
- )} {/* Inspector pre-sign — the envelope-level signature an inspector applies before the client sees it. Moved off the Library page with the rest of diff --git a/docs/develop/fork-log.md b/docs/develop/fork-log.md index c88e8f4f..9f740821 100644 --- a/docs/develop/fork-log.md +++ b/docs/develop/fork-log.md @@ -331,3 +331,4 @@ deliberate departure rather than work upstream simply has not done yet. - Record skips as explicitly as takes. "We looked and said no" and "we never looked" are the same silence otherwise. - Update the snapshot when you fetch, not when you remember. +| `agreement` merge fields filled at send, placeholders block the send (2026-10-13 QA sweep DELIV-01, Blocker; no upstream fix) | `AgreementService.findOrCreate` (`server/services/agreement/envelope-legacy.ts`) runs the template through `fillAgreementTokens` (`server/lib/agreement-tokens.ts`) before it freezes `content_snapshot`: `[INSPECTOR_NAME]` (assigned inspector, else the company's legal or company name), `[CUSTOMER_NAME]`, `[PROPERTY_ADDRESS]`, `[INSPECTION_DATE]` (long civil day) and `$[AMOUNT]` (`getEffectivePriceCents`: invoice, then service lines, then `inspections.price`); `[CLIENT_SIGNATURE_BLOCK]` and `[SIGN_DATE]` become "(signature captured below)" and "(recorded at signing)", values HTML-escaped. If any other bracketed ALL-CAPS token remains, or the starter's "Review before sending to real customers" notice is still in the text, it throws 422 naming them and creates no envelope. `SendAgreementModal` takes `serverError` and shows it inside the dialog (the page-level line it replaces sat under the scrim, so Send looked dead). Specs: `tests/unit/agreements/agreement-send-tokens.spec.ts`, `app/components/agreements/SendAgreementModal.test.tsx` | The finding: a fresh company's only template could be sent and signed with every merge field still in brackets and the admin caveat in the permanent record. `git log main..upstream/main` by keyword (placeholder, merge field, starter agreement, agreement) and on the fixture file finds nothing; the upstream plan lists it under its PR 5 but upstream has not landed it. The starter text is unchanged, so an untouched starter is refused (notice and the liability clause both stay) until the company edits it in Settings; that is the intended state of a legal document, not a gap. **Known ceilings:** the match is `[A-Z][A-Z0-9_]{2,}`-style brackets only, so a custom template with lowercase or `{{x}}` fields is sent as written; the check runs only when an envelope is created (reusing a live envelope does not re-check); an inspection with no price blocks on `[AMOUNT]` rather than printing $0.00; the signing page itself still shows what the snapshot holds, so envelopes sent before this change keep their placeholders | diff --git a/server/lib/agreement-tokens.ts b/server/lib/agreement-tokens.ts new file mode 100644 index 00000000..85af2d3b --- /dev/null +++ b/server/lib/agreement-tokens.ts @@ -0,0 +1,61 @@ +/** + * Send-time merge fields for an agreement template. + * + * The starter template ships `[INSPECTOR_NAME]`, `[CUSTOMER_NAME]`, + * `[PROPERTY_ADDRESS]`, `[INSPECTION_DATE]` and `$[AMOUNT]`. They are filled + * from the inspection when the envelope is created, because the envelope's + * `content_snapshot` is the permanent signed record: whatever is still in + * brackets here is what the client signs and what a dispute produces. + * + * Anything bracketed that is NOT filled, and the starter's "review before + * sending" notice, block the send instead (`blockingAgreementIssues`). + */ + +export interface AgreementTokenValues { + inspector?: string | null | undefined; + client?: string | null | undefined; + address?: string | null; + /** Civil day, `YYYY-MM-DD` (an `T…Z` suffix is ignored). */ + date?: string | null; + amountCents?: number | null; +} + +/** Substring of the starter template's admin-only caveat paragraph. */ +const STARTER_NOTICE_MARK = 'Review before sending to real customers'; + +const esc = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +const longDay = (d: string): string | null => { + const t = new Date(`${d.slice(0, 10)}T12:00:00Z`); + return Number.isNaN(t.getTime()) ? null : t.toLocaleDateString('en-US', { timeZone: 'UTC', dateStyle: 'long' }); // i18n-lint-ok: the agreement body is English-only and frozen into content_snapshot +}; + +export function fillAgreementTokens(html: string, v: AgreementTokenValues): string { + const day = v.date ? longDay(v.date) : null; + const map: Record⚠️.*?<\/p>/, '');
+
+describe('fillAgreementTokens / blockingAgreementIssues', () => {
+ it('fills the starter fields and leaves nothing blocking', () => {
+ const out = fillAgreementTokens(withoutNotice.replace(/ [CUSTOMER_NAME] pays $[AMOUNT] Pat pays $[AMOUNT]4\..*?<\/p>/, ''), {
+ inspector: 'Quinn
4\..*?<\/p>/, ''));
+ const r = await svc.findOrCreate(TENANT, INSP_ID);
+ const env = await db.select().from(schema.agreementRequests).where(eq(schema.agreementRequests.id, r.requestId)).get();
+ expect(env?.contentSnapshot).toContain('Quinn Assurance ("Inspector")');
+ expect(env?.contentSnapshot).toContain('Pat Rivera ("Client")');
+ expect(env?.contentSnapshot).toContain('742 Ivy Lane, Charlotte, NC on October 20, 2026');
+ expect(env?.contentSnapshot).toContain('$400.00');
+ expect(env?.contentSnapshot).not.toMatch(/\[[A-Z_]{3,}/);
+ });
+});