Files
red/.planning/phases/15-multi-signer-backend/15-CONTEXT.md
2026-04-03 15:26:34 -06:00

9.8 KiB

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 approachpreparedFilePath 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_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.tscreateSigningToken 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.tsDocumentSigner 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>

## 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