docs(phase-15): gather context for multi-signer backend phase
This commit is contained in:
0
.planning/phases/15-multi-signer-backend/.gitkeep
Normal file
0
.planning/phases/15-multi-signer-backend/.gitkeep
Normal file
137
.planning/phases/15-multi-signer-backend/15-CONTEXT.md
Normal file
137
.planning/phases/15-multi-signer-backend/15-CONTEXT.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Phase 15: Multi-Signer Backend - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-03
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## 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
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope.
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 15-multi-signer-backend*
|
||||||
|
*Context gathered: 2026-04-03*
|
||||||
Reference in New Issue
Block a user