9.8 KiB
9.8 KiB
Phase 15: Multi-Signer Backend - Context
Gathered: 2026-04-03 Status: Ready for planning
## Phase BoundaryBackend-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:
POST /api/documents/[id]/send— token creation loop + parallel email dispatchGET /api/sign/[token]— filter fields to the token's assigned signerPOST /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 DecisionsLegacy Single-Signer Fallback
- D-01: When
documents.signersis null or empty, the send route falls back to the existing single-signer behavior: create one token (nosignerEmailset), send todoc.assignedClientId ?? doc.clientId. This preserves backwards compatibility for all existing documents. New multi-signer documents havesignerspopulated (by Phase 16's PreparePanel UI).
Send Route Rewrite
- D-02: When
documents.signersis populated, loop over each signer, callcreateSigningToken(documentId)for each (passingsignerEmail), and dispatch all emails in parallel usingPromise.all. All signing links sent simultaneously. - D-03:
createSigningTokenmust be extended to accept an optionalsignerEmailparameter and write it to thesigningTokens.signerEmailcolumn. This is the only change to the token utility.
GET Sign Handler — Field Filtering
- D-04: If
tokenRow.signerEmailis NOT NULL: filtersignatureFieldsto fields wherefield.signerEmail === tokenRow.signerEmail. Only fields belonging to this signer are returned. - D-05: If
tokenRow.signerEmailIS NULL (legacy token): return allisClientVisibleFieldfields — same as today. - D-06: Use
getSignerEmail(field, null)(the helper added in Phase 14) to read field signer. The filter:field.signerEmail === tokenRow.signerEmailworks 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:- Check if ALL tokens for this document have
usedAt IS NOT NULL(count claimed vs total) - If not all claimed: do nothing to document status
- If all claimed: attempt to claim
completionTriggeredAtviaUPDATE documents SET completionTriggeredAt = NOW() WHERE id = $1 AND completionTriggeredAt IS NULL RETURNING id - Only if that UPDATE returns a row: set
status = 'Signed',signedAt = now,signedFilePath,pdfHash, and trigger all-parties notifications
- Check if ALL tokens for this document have
- 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 fromsigningTokens.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
workingFilePathin the database (or derive from signingTokens order), always read the latest intermediate when a signer submits. - Simpler approach: all signers read from
preparedFilePathand write to their own_partial_{jti}.pdf; the completion handler reads all partial files and merges them. - Decision: use the ordered accumulate approach —
preparedFilePathacts 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 usesignedFilePathas the working copy (reset to latest partial on each signing). - Concrete implementation: keep
documents.signedFilePathas the "current working PDF" — after each signer signs, updatesignedFilePathto their output. The next signer reads fromsignedFilePath(if it exists) instead ofpreparedFilePath. Final signer's output IS the final_signed.pdf.
- Implementation: track a
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/verifyAgentDownloadTokenpattern from Phase 7. The download route already exists atGET /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 finalsignedFilePathwithout requiring an agent auth session.
- Concrete approach: Create a new public download route
- D-12: The agent notification email (already exists:
sendAgentNotificationEmail) is called on completion. Add asendSignerCompletionEmailfunction that sends the download link to each signer.
Claude's Discretion
- Exact intermediate file naming convention for partial PDFs
- Whether to add
viewedAttimestamp 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_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—createSigningTokenneeds optionalsignerEmailparamteressa-copeland-homes/src/lib/signing/signing-mailer.tsx— AddsendSignerCompletionEmailfunction
Schema (Phase 14 output — critical)
teressa-copeland-homes/src/lib/db/schema.ts—DocumentSignerinterface,getSignerEmail()helper, new nullable columns ondocumentsandsigningTokens
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)intoken.ts— Extend with optionalsignerEmailparam; writes tosigningTokens.jtiwith the newsignerEmailcolumnsendSigningRequestEmail()insigning-mailer.tsx— Already acceptsto,clientName,documentName,signingUrl,expiresAt— reusable as-is in the token loopsendAgentNotificationEmail()— Already called in POST handler; keep calling it on final completion only (not per-signer)embedSignatureInPdf()insigning/embed-signature.ts— Reuse as-is; accumulate pattern just changes the INPUT path per signercreateAgentDownloadToken()/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 forcompletionTriggeredAt - 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 timesigningTokens.signerEmail(Phase 14) — written here (send route), read here (GET/POST sign handler)documents.completionTriggeredAt(Phase 14) — written here ONLY; used as atomic completion guarddocuments.signedFilePath— repurposed as "working PDF path" during accumulate; final value = the last signer's output
</code_context>
## Specific Ideas- The accumulate PDF approach: after each signer signs, update
documents.signedFilePathto point to the latest partial PDF. The next signer reads fromsignedFilePath(if not null) rather thanpreparedFilePath. This meanssignedFilePathholds 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, servessignedFilePath, no agent auth required.
None — discussion stayed within phase scope.
Phase: 15-multi-signer-backend Context gathered: 2026-04-03