From 817d53ae123a51f0484e01368f31108f6b92f4ce Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 3 Apr 2026 15:26:34 -0600 Subject: [PATCH] docs(phase-15): gather context for multi-signer backend phase --- .../phases/15-multi-signer-backend/.gitkeep | 0 .../15-multi-signer-backend/15-CONTEXT.md | 137 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 .planning/phases/15-multi-signer-backend/.gitkeep create mode 100644 .planning/phases/15-multi-signer-backend/15-CONTEXT.md diff --git a/.planning/phases/15-multi-signer-backend/.gitkeep b/.planning/phases/15-multi-signer-backend/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.planning/phases/15-multi-signer-backend/15-CONTEXT.md b/.planning/phases/15-multi-signer-backend/15-CONTEXT.md new file mode 100644 index 0000000..db19d41 --- /dev/null +++ b/.planning/phases/15-multi-signer-backend/15-CONTEXT.md @@ -0,0 +1,137 @@ +# Phase 15: Multi-Signer Backend - Context + +**Gathered:** 2026-04-03 +**Status:** Ready for planning + + +## Phase Boundary + +Backend-only rewrite of three existing API routes. No schema changes (those are Phase 14), no UI changes (those are Phase 16). Phase 15 makes the server correctly handle multiple signers: one token per signer at send time, signer-filtered signing pages, atomic completion detection, and all-parties final PDF delivery via download link. + +The three routes being changed: +1. `POST /api/documents/[id]/send` — token creation loop + parallel email dispatch +2. `GET /api/sign/[token]` — filter fields to the token's assigned signer +3. `POST /api/sign/[token]` — fix first-signer-wins bug + atomic completion + per-signer date stamping + final PDF delivery + +No new routes. No schema migrations. No UI components. + + + + +## Implementation Decisions + +### Legacy Single-Signer Fallback + +- **D-01:** When `documents.signers` is null or empty, the send route falls back to the existing single-signer behavior: create one token (no `signerEmail` set), send to `doc.assignedClientId ?? doc.clientId`. This preserves backwards compatibility for all existing documents. New multi-signer documents have `signers` populated (by Phase 16's PreparePanel UI). + +### Send Route Rewrite + +- **D-02:** When `documents.signers` is populated, loop over each signer, call `createSigningToken(documentId)` for each (passing `signerEmail`), and dispatch all emails in parallel using `Promise.all`. All signing links sent simultaneously. +- **D-03:** `createSigningToken` must be extended to accept an optional `signerEmail` parameter and write it to the `signingTokens.signerEmail` column. This is the only change to the token utility. + +### GET Sign Handler — Field Filtering + +- **D-04:** If `tokenRow.signerEmail` is NOT NULL: filter `signatureFields` to fields where `field.signerEmail === tokenRow.signerEmail`. Only fields belonging to this signer are returned. +- **D-05:** If `tokenRow.signerEmail` IS NULL (legacy token): return all `isClientVisibleField` fields — same as today. +- **D-06:** Use `getSignerEmail(field, null)` (the helper added in Phase 14) to read field signer. The filter: `field.signerEmail === tokenRow.signerEmail` works because both are the same email string (or both are null for legacy). + +### POST Sign Handler — First-Signer-Wins Fix + Completion + +- **D-07:** After claiming the token atomically, do NOT unconditionally set `documents.status = 'Signed'`. Instead: + 1. Check if ALL tokens for this document have `usedAt IS NOT NULL` (count claimed vs total) + 2. If not all claimed: do nothing to document status + 3. If all claimed: attempt to claim `completionTriggeredAt` via `UPDATE documents SET completionTriggeredAt = NOW() WHERE id = $1 AND completionTriggeredAt IS NULL RETURNING id` + 4. Only if that UPDATE returns a row: set `status = 'Signed'`, `signedAt = now`, `signedFilePath`, `pdfHash`, and trigger all-parties notifications +- **D-08:** The document status is set to 'Signed' ONLY when all signers have submitted. Intermediate state (some signed, some not) is NOT written to `documents.status` — it remains 'Sent'. Per-signer completion is readable from `signingTokens.usedAt` (Phase 16 uses this for the dashboard). + +### Date Field Per-Signer Scoping + +- **D-09:** When embedding date stamps, filter to ONLY the date fields where `field.signerEmail === tokenRow.signerEmail` (or, for legacy null-signer tokens, all date fields as today). Each signer's date fields are stamped with THEIR submission timestamp, not a shared timestamp. + +### Final PDF Assembly — Accumulate + +- **D-10:** Each signer reads the current "working" PDF as input (starting from `preparedFilePath`), embeds their signatures and date fields, and writes back to a per-signer intermediate file (e.g., `{base}_partial_{signerIndex}.pdf`). The intermediate file becomes the input for the next signer. The final signer's output becomes `_signed.pdf`. + - Implementation: track a `workingFilePath` in the database (or derive from signingTokens order), always read the latest intermediate when a signer submits. + - Simpler approach: all signers read from `preparedFilePath` and write to their own `_partial_{jti}.pdf`; the completion handler reads all partial files and merges them. + - **Decision: use the ordered accumulate approach** — `preparedFilePath` acts as the base; each signer embeds on top of whatever the current "most complete" PDF is. Store the latest signed path on the document as an intermediate field, or use `signedFilePath` as the working copy (reset to latest partial on each signing). + - **Concrete implementation:** keep `documents.signedFilePath` as the "current working PDF" — after each signer signs, update `signedFilePath` to their output. The next signer reads from `signedFilePath` (if it exists) instead of `preparedFilePath`. Final signer's output IS the final `_signed.pdf`. + +### All-Parties Final PDF Delivery + +- **D-11:** On completion, send all parties (agent + all signers) an email containing a **time-limited signed download URL** — not an attachment. Reuse the existing `createAgentDownloadToken` / `verifyAgentDownloadToken` pattern from Phase 7. The download route already exists at `GET /api/documents/[id]/download`. Extend it to allow signer access (currently auth-gated — need an unauthenticated signer download path, or pass the presigned token as a query param that bypasses auth for signed documents only). + - **Concrete approach:** Create a new public download route `GET /api/sign/download/[token]` that accepts a presigned signer-download token (same JWT pattern) and serves the final `signedFilePath` without requiring an agent auth session. +- **D-12:** The agent notification email (already exists: `sendAgentNotificationEmail`) is called on completion. Add a `sendSignerCompletionEmail` function that sends the download link to each signer. + +### Claude's Discretion +- Exact intermediate file naming convention for partial PDFs +- Whether to add `viewedAt` timestamp to signingTokens (for Phase 16 dashboard — planner can decide if it fits cleanly) +- Error handling granularity (individual signer email failure should not block the send) + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Files Being Modified +- `teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts` — Send route (single-token → token loop) +- `teressa-copeland-homes/src/app/api/sign/[token]/route.ts` — GET + POST sign handler (full rewrite of both) +- `teressa-copeland-homes/src/lib/signing/token.ts` — `createSigningToken` needs optional `signerEmail` param +- `teressa-copeland-homes/src/lib/signing/signing-mailer.tsx` — Add `sendSignerCompletionEmail` function + +### Schema (Phase 14 output — critical) +- `teressa-copeland-homes/src/lib/db/schema.ts` — `DocumentSigner` interface, `getSignerEmail()` helper, new nullable columns on `documents` and `signingTokens` + +### Research +- `.planning/research/ARCHITECTURE.md` — Completion detection pattern (advisory lock), multi-signer token loop +- `.planning/research/PITFALLS.md` — First-signer-wins bug (line 254-258 in sign handler), race condition on completion + +### Phase 14 Context +- `.planning/phases/14-multi-signer-schema/14-CONTEXT.md` — All schema decisions (D-01 through D-07) + + + + +## Existing Code Insights + +### Reusable Assets +- `createSigningToken(documentId)` in `token.ts` — Extend with optional `signerEmail` param; writes to `signingTokens.jti` with the new `signerEmail` column +- `sendSigningRequestEmail()` in `signing-mailer.tsx` — Already accepts `to`, `clientName`, `documentName`, `signingUrl`, `expiresAt` — reusable as-is in the token loop +- `sendAgentNotificationEmail()` — Already called in POST handler; keep calling it on final completion only (not per-signer) +- `embedSignatureInPdf()` in `signing/embed-signature.ts` — Reuse as-is; accumulate pattern just changes the INPUT path per signer +- `createAgentDownloadToken()` / `verifyAgentDownloadToken()` — JWT pattern for presigned downloads; extend for signer-download tokens + +### Established Patterns +- **Atomic token claim:** `UPDATE ... WHERE usedAt IS NULL RETURNING` (line 127-131 in sign route) — same pattern for `completionTriggeredAt` +- **Fire-and-forget emails:** `db.query...then(async () => sendEmail()).catch(...)` pattern (lines 267-286) — follow same pattern for signer completion emails +- **Path traversal guard:** `!path.startsWith(UPLOADS_DIR)` check (lines 169-171) — replicate for the new signer download route +- **Audit logging:** `logAuditEvent()` called at each stage — continue logging on each signer's submission + +### Integration Points +- `documents.signers` (Phase 14) — populated by Phase 16's PreparePanel; read here to loop over signers at send time +- `signingTokens.signerEmail` (Phase 14) — written here (send route), read here (GET/POST sign handler) +- `documents.completionTriggeredAt` (Phase 14) — written here ONLY; used as atomic completion guard +- `documents.signedFilePath` — repurposed as "working PDF path" during accumulate; final value = the last signer's output + + + + +## Specific Ideas + +- The accumulate PDF approach: after each signer signs, update `documents.signedFilePath` to point to the latest partial PDF. The next signer reads from `signedFilePath` (if not null) rather than `preparedFilePath`. This means `signedFilePath` holds an intermediate value during multi-signing — document status remains 'Sent' to indicate it's not complete. +- New public download route for signers: `GET /api/sign/download/[token]` — accepts a short-lived JWT, serves `signedFilePath`, no agent auth required. + + + + +## Deferred Ideas + +None — discussion stayed within phase scope. + + + +--- + +*Phase: 15-multi-signer-backend* +*Context gathered: 2026-04-03*