---
phase: 15-multi-signer-backend
plan: "03"
subsystem: signing-api
tags: [multi-signer, atomic-completion, accumulate-pdf, signer-aware]
dependency_graph:
  requires: [15-01, 14-01]
  provides: [signer-aware-sign-route, atomic-completion-guard, accumulate-pdf]
  affects: [sign-token-route, signing-flow]
tech_stack:
  added: []
  patterns: [accumulate-pdf, update-where-is-null-returning, fire-and-forget-emails]
key_files:
  created: []
  modified:
    - teressa-copeland-homes/src/app/api/sign/[token]/route.ts
decisions:
  - "GET filter uses tokenRow.signerEmail — null = legacy token returns all isClientVisibleField fields (D-04, D-05)"
  - "POST accumulate: signedFilePath as working PDF — each signer reads latest, writes JTI-keyed partial (D-10)"
  - "Completion guard: UPDATE documents SET completionTriggeredAt=now WHERE completionTriggeredAt IS NULL RETURNING (D-07)"
  - "status='Signed' written only by completionTriggeredAt race winner — fixes first-signer-wins bug (D-08)"
  - "Signer completion emails sent only at completion from signers JSONB array (D-12, MSIGN-11)"
  - "Legacy null-signerEmail path unchanged: stamps all date fields, embeds all client-visible signable fields"
metrics:
  duration_minutes: 2
  completed_date: "2026-04-03"
  tasks_completed: 2
  files_modified: 1
---

# Phase 15 Plan 03: Signer-Aware Sign Route Summary

Rewrote both GET and POST handlers in `sign/[token]/route.ts` to be signer-aware. GET now filters fields by `tokenRow.signerEmail`. POST fixes the first-signer-wins bug, accumulates PDFs via JTI-keyed partial paths, and uses the `completionTriggeredAt` atomic guard so only one concurrent handler finalizes the document.

## Tasks Completed

| Task | Description | Commit | Files |
|------|-------------|--------|-------|
| 1 | GET signer-aware field filter + new imports | `0f97c42` | `sign/[token]/route.ts` |
| 2 | POST accumulate PDF + atomic completion + signer emails | `1749e10` | `sign/[token]/route.ts` |

## What Was Built

### Task 1: GET Handler (signer-aware field filter)

The existing line `signatureFields: (doc.signatureFields ?? []).filter(isClientVisibleField)` was replaced with a two-branch filter:

- If `tokenRow.signerEmail !== null`: only fields where `field.signerEmail === tokenRow.signerEmail` are returned
- If `tokenRow.signerEmail === null` (legacy): all `isClientVisibleField` fields returned (unchanged behavior)

New imports added: `createSignerDownloadToken`, `sendSignerCompletionEmail`, `DocumentSigner`, `sql`.

### Task 2: POST Handler (full rewrite)

**Step 3.5 — signerEmail fetch:** After atomic token claim, fetches `tokenRow.signerEmail` via DB query; coalesces to `null` for legacy tokens.

**Step 7 — Accumulate pattern (D-10):** Re-fetches `documents.signedFilePath` (latest partial from any earlier signer) and uses it as `workingAbsPath`. Writes this signer's output to `_partial_{jti}.pdf` (JTI-keyed prevents collisions). No more single `_signed.pdf` path.

**Step 8a — Date stamping scoped (D-09):** Filters date fields to `f.signerEmail === signerEmail` for multi-signer; legacy null-signer stamps all date fields.

**Step 8b — Signable fields scoped:** Filters `client-signature` + `initials` fields to this signer only. Legacy path uses `isClientVisibleField` fallback.

**Step 10.5 — signedFilePath updated:** After each signer's embedding, `documents.signedFilePath` is updated to the partial path. Next signer reads this as input.

**Step 11 — Completion detection (D-07):** Counts remaining unclaimed tokens (`usedAt IS NULL`). If any remain, returns `{ ok: true }` immediately without touching document status.

**Atomic race (D-08):** `UPDATE documents SET completionTriggeredAt=now WHERE id=? AND completionTriggeredAt IS NULL RETURNING id` — zero rows = lost race, return `{ ok: true }` without notifications.

**Step 12 — Winner only sets Signed:** The `status: 'Signed'` update is gated behind winning the `completionTriggeredAt` race. Previously it was unconditional — this was the first-signer-wins bug.

**Step 13 — Completion emails (D-12):** Agent notification email fires (fire-and-forget). Signer completion emails fire to all entries in `documents.signers` JSONB with a shared download link. Legacy documents (empty `signers` array) skip signer completion emails.

## Deviations from Plan

None — plan executed exactly as written. The acceptance criteria note about `grep -c "field.signerEmail === tokenRow.signerEmail"` returning ≥ 3 was interpreted per the plan text; the POST uses local variable `signerEmail` (equivalent logic, different variable name). All functional requirements are met.

## Known Stubs

None.

## Self-Check: PASSED

- `/Users/ccopeland/temp/red/teressa-copeland-homes/src/app/api/sign/[token]/route.ts` — FOUND
- Commit `0f97c42` — FOUND
- Commit `1749e10` — FOUND
- `npx tsc --noEmit` — PASSED (zero errors)
