diff --git a/.planning/phases/15-multi-signer-backend/15-RESEARCH.md b/.planning/phases/15-multi-signer-backend/15-RESEARCH.md new file mode 100644 index 0000000..57a5e2e --- /dev/null +++ b/.planning/phases/15-multi-signer-backend/15-RESEARCH.md @@ -0,0 +1,674 @@ +# Phase 15: Multi-Signer Backend - Research + +**Researched:** 2026-04-03 +**Domain:** Next.js App Router API routes — multi-signer token lifecycle, atomic completion detection, accumulate PDF, JWT-presigned signer download +**Confidence:** HIGH — all findings grounded in direct codebase inspection of the three routes being changed, the token utility, signing-mailer, embed-signature, and schema.ts; no speculative claims + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**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`. Backwards compatibility preserved. + +**D-02:** When `documents.signers` is populated, loop over each signer, call `createSigningToken(documentId)` for each (passing `signerEmail`), dispatch all emails in parallel via `Promise.all`. All signing links sent simultaneously. + +**D-03:** `createSigningToken` must be extended to accept an optional `signerEmail` parameter and write it to `signingTokens.signerEmail`. Only change to the token utility. + +**D-04:** If `tokenRow.signerEmail` is NOT NULL: filter `signatureFields` to fields where `field.signerEmail === tokenRow.signerEmail`. Only this signer's fields returned. + +**D-05:** If `tokenRow.signerEmail` IS NULL (legacy token): return all `isClientVisibleField` fields — same as today. + +**D-06:** Use `getSignerEmail(field, null)` helper to read field signer. Filter: `field.signerEmail === tokenRow.signerEmail` (both are same email string, or both null for legacy). + +**D-07:** After claiming token atomically: +1. Check if ALL tokens for this document have `usedAt IS NOT NULL` +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:** Document status set to 'Signed' ONLY when all signers have submitted. Intermediate state remains 'Sent'. Per-signer completion readable from `signingTokens.usedAt`. + +**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). Each signer's date fields stamped with THEIR submission timestamp. + +**D-10 (Accumulate PDF):** 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`. + +**D-11 (Download route):** 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 only. 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) + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| MSIGN-05 | Document recipients list is built automatically from unique signer emails on placed fields (no separate manual entry) | Send route reads `doc.signers[]` populated by Phase 16 UI; D-01/D-02 cover the loop | +| MSIGN-06 | All signers receive their unique signing links simultaneously when agent sends | `Promise.all` token loop in send route (D-02); `createSigningToken` extended (D-03) | +| MSIGN-07 | Each signer's signing page shows only their own assigned fields — other signers' fields are not visible | GET handler field filtering by `tokenRow.signerEmail` (D-04/D-05/D-06) | +| MSIGN-10 | When all signers complete, agent receives a notification email | `sendAgentNotificationEmail` called only at completion trigger (D-07/D-12) | +| MSIGN-11 | When all signers complete, all parties (each signer + agent) receive the final merged PDF via email link | `sendSignerCompletionEmail` + new public download route (D-11/D-12) | + + +--- + +## Summary + +Phase 15 is a surgical rewrite of three existing routes: no schema migrations (Phase 14 shipped those), no UI. The Phase 14 schema additions (`signers`, `completionTriggeredAt`, `signerEmail`) are already in `schema.ts` and in the DB — this phase writes the application logic that uses them. + +The most important thing to understand about the current code: the POST sign handler at `src/app/api/sign/[token]/route.ts` lines 254-263 unconditionally sets `status = 'Signed'` after any token claim. This is the first-signer-wins bug. Everything else in the handler is correct and reusable — the atomic token claim pattern, the date-stamp approach, the `embedSignatureInPdf` call, the fire-and-forget email pattern. The rewrite preserves all of that and wraps the completion logic in a two-step guard: count remaining unclaimed tokens, then race for `completionTriggeredAt`. + +The accumulate PDF strategy uses `documents.signedFilePath` as the live working path. During multi-signing, this column holds a partial/intermediate signed PDF (document status remains 'Sent' — signedFilePath being non-null during Sent status is the new normal for multi-signer documents). Only the completion winner writes the final hash and flips status to 'Signed'. + +The new public download route `GET /api/sign/download/[token]` follows the existing `createAgentDownloadToken` / `verifyAgentDownloadToken` pattern exactly — a short-lived JWT with `purpose: 'signer-download'`, no DB record, served without auth session. This is a new file. + +**Primary recommendation:** Implement in the order: (1) extend `createSigningToken`, (2) rewrite send route with legacy fallback, (3) rewrite GET sign handler field filtering, (4) rewrite POST sign handler with accumulate + completion, (5) add `sendSignerCompletionEmail` and the new download route. + +--- + +## Standard Stack + +### Core (already installed — no new dependencies) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `jose` | existing | JWT signing/verification for signing tokens and new signer-download tokens | Already used in `token.ts` for all token operations | +| `drizzle-orm` | existing | DB queries — token claim, completion guard, doc updates | Pattern established in existing routes | +| `@cantoo/pdf-lib` | existing | Date stamping and (via embed-signature.ts) PDF accumulation | Already used in sign handler lines 183-204 | +| `nodemailer` | existing | Signer completion email delivery | Already used in `signing-mailer.tsx` | +| `@react-email/render` | existing | HTML email rendering | Already used in `sendSigningRequestEmail` | + +No new npm packages are required for this phase. + +--- + +## Architecture Patterns + +### Pattern 1: Existing Atomic Token Claim (already proven — preserve as-is) + +The current POST handler at lines 127-135 uses: + +```typescript +// Source: teressa-copeland-homes/src/app/api/sign/[token]/route.ts lines 127-135 +const claimed = await db + .update(signingTokens) + .set({ usedAt: new Date() }) + .where(and(eq(signingTokens.jti, payload.jti), isNull(signingTokens.usedAt))) + .returning({ jti: signingTokens.jti }); + +if (claimed.length === 0) { + return NextResponse.json({ error: 'already-signed' }, { status: 409 }); +} +``` + +This is the correct pattern. Do not change it. The completion guard uses the same idiom. + +### Pattern 2: Completion Guard — `completionTriggeredAt` Race + +The schema already has `completionTriggeredAt` on `documents`. The guard: + +```typescript +// Source: .planning/research/PITFALLS.md Pitfall 2 — verified against schema.ts line 115 +const won = await db + .update(documents) + .set({ completionTriggeredAt: new Date() }) + .where(and( + eq(documents.id, payload.documentId), + isNull(documents.completionTriggeredAt) + )) + .returning({ id: documents.id }); + +if (won.length === 0) return; // another handler already won the race +// Only the winner proceeds to final PDF hash + status update + notifications +``` + +This must run AFTER the "all tokens claimed" count check. Only attempt the completion guard when count(unclaimed) === 0. + +### Pattern 3: All-Tokens-Claimed Check + +```typescript +// Source: .planning/research/ARCHITECTURE.md Part 2 signing POST sequence step 9 +const [totalRow, claimedRow] = await Promise.all([ + db.select({ count: count() }) + .from(signingTokens) + .where(eq(signingTokens.documentId, payload.documentId)), + db.select({ count: count() }) + .from(signingTokens) + .where(and( + eq(signingTokens.documentId, payload.documentId), + isNotNull(signingTokens.usedAt) + )), +]); +const allSigned = totalRow[0].count === claimedRow[0].count; +``` + +Alternatively, a single query: `SELECT COUNT(*) FILTER (WHERE used_at IS NULL) AS remaining FROM signing_tokens WHERE document_id = $1`. Zero remaining = all signed. + +### Pattern 4: Accumulate PDF — signedFilePath as Working Copy + +```typescript +// Source: .planning/phases/15-multi-signer-backend/15-CONTEXT.md D-10 +// Re-read doc to get latest signedFilePath (another signer may have updated it) +const freshDoc = await db.query.documents.findFirst({ + where: eq(documents.id, payload.documentId), + columns: { signedFilePath: true, preparedFilePath: true }, +}); + +const workingRelPath = freshDoc?.signedFilePath ?? freshDoc?.preparedFilePath; +const workingAbsPath = path.join(UPLOADS_DIR, workingRelPath!); + +// Write this signer's output to a new partial path +const partialRelPath = `clients/${docId}/${jti}_partial.pdf`; +const partialAbsPath = path.join(UPLOADS_DIR, partialRelPath); + +// embed into workingAbsPath, write to partialAbsPath +await embedSignatureInPdf(dateStampedPath ?? workingAbsPath, partialAbsPath, signaturesWithCoords); + +// Update signedFilePath to the new partial — becomes the next signer's working copy +await db.update(documents) + .set({ signedFilePath: partialRelPath }) + .where(eq(documents.id, payload.documentId)); +``` + +IMPORTANT: The advisory lock is required here. Two concurrent signers both reading `signedFilePath` and both writing to different partial paths would race. The lock ensures sequential reads. + +### Pattern 5: Postgres Advisory Lock + +```typescript +// Source: .planning/research/ARCHITECTURE.md Part 2 Advisory Lock Implementation +// Run this as the FIRST thing inside a transaction context (or as standalone execute) +await db.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${payload.documentId}))`); +``` + +`pg_advisory_xact_lock` is transaction-scoped — released automatically when the transaction commits or rolls back. Drizzle supports `db.execute(sql`...`)` for raw SQL. + +IMPORTANT: The existing sign route does NOT use a transaction. For the advisory lock to be transaction-scoped, the lock + PDF write + signedFilePath update must happen inside `db.transaction(async (tx) => { ... })`. If running without a transaction, use `pg_advisory_lock` / `pg_advisory_unlock` instead (session-level, explicit release). + +Simpler approach that avoids transactions: use the `completionTriggeredAt` guard alone (already written above) and accept that the accumulate PDF step is serialized by the fact that each signer writes to a unique JTI-keyed partial path. The only race is on completion — which is handled by `completionTriggeredAt`. The intermediate partial PDFs are not corrupted by concurrent writes because they have unique output paths. + +### Pattern 6: New signer-download Token — reuse existing pattern + +```typescript +// Source: teressa-copeland-homes/src/lib/signing/token.ts lines 51-64 +// Follow the exact same structure as createAgentDownloadToken / verifyAgentDownloadToken + +export async function createSignerDownloadToken(documentId: string): Promise { + return await new SignJWT({ documentId, purpose: 'signer-download' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('72h') // longer than agent-download (5m) — signer may not open immediately + .sign(getSecret()); +} + +export async function verifySignerDownloadToken(token: string): Promise<{ documentId: string }> { + const { payload } = await jwtVerify(token, getSecret()); + if (payload['purpose'] !== 'signer-download') throw new Error('Not a signer download token'); + return { documentId: payload['documentId'] as string }; +} +``` + +The public download route `GET /api/sign/download/[token]` reads the token from the path param, verifies it, fetches the document's `signedFilePath`, checks it's within UPLOADS_DIR (path traversal guard — replicating lines 169-171 of the sign route), and serves the file as `application/pdf`. + +### Pattern 7: Extended createSigningToken + +```typescript +// Source: teressa-copeland-homes/src/lib/signing/token.ts current signature +// Current: createSigningToken(documentId: string) +// New: createSigningToken(documentId: string, signerEmail?: string) + +export async function createSigningToken( + documentId: string, + signerEmail?: string +): Promise<{ token: string; jti: string; expiresAt: Date }> { + // ... JWT construction unchanged ... + await db.insert(signingTokens).values({ + jti, + documentId, + signerEmail: signerEmail ?? null, // new column — null for legacy tokens + expiresAt, + }); + return { token, jti, expiresAt }; +} +``` + +All existing call sites (`createSigningToken(doc.id)`) compile without modification — the second param is optional. + +### Pattern 8: Fire-and-forget email (established pattern — preserve) + +```typescript +// Source: teressa-copeland-homes/src/app/api/sign/[token]/route.ts lines 267-286 +// Existing fire-and-forget pattern for agent notification — reuse for signer completion emails +somePromise + .then(async () => { + try { await sendEmail(...); } + catch (err) { console.error('[sign/POST] email failed (non-fatal):', err); } + }) + .catch((err) => { console.error('[sign/POST] fetch failed (non-fatal):', err); }); +``` + +For MSIGN-11: send completion email to each signer at completion. Individual email failure must NOT block the response (fire-and-forget per signer). Log failures but return 200. + +### Pattern 9: Send Route Legacy Fallback Structure + +```typescript +// Source: CONTEXT.md D-01/D-02 +if (doc.signers && doc.signers.length > 0) { + // Multi-signer path: create one token per signer, send all emails in parallel + await Promise.all( + doc.signers.map(async (signer) => { + const { token, expiresAt } = await createSigningToken(doc.id, signer.email); + const signingUrl = `${baseUrl}/sign/${token}`; + await sendSigningRequestEmail({ to: signer.email, documentName: doc.name, signingUrl, expiresAt }); + await logAuditEvent({ documentId: doc.id, eventType: 'email_sent', metadata: { signerEmail: signer.email } }); + }) + ); +} else { + // Legacy single-signer path: exact current behavior + const { token, expiresAt } = await createSigningToken(doc.id); + // ... existing code verbatim ... +} +``` + +Note: `doc.signers` type is `DocumentSigner[] | null` from Phase 14 schema. The `DocumentSigner` interface has `{ email: string; color: string }` — only `email` is needed in this route. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Atomic "only one handler wins" on completion | Custom in-memory mutex, file locks | `UPDATE ... WHERE IS NULL RETURNING` on `completionTriggeredAt` | Same DB-level atomicity already used for token claim in line 127-131 | +| PDF file concurrency | Custom queue, setTimeout delays | Postgres advisory lock OR unique JTI-keyed output paths (see Pattern 5) | Advisory lock is a single SQL call; unique paths avoid contention entirely for intermediate partials | +| Short-lived presigned download URLs | Custom signed URL scheme | `jose` SignJWT with `purpose: 'signer-download'` | Exact same pattern as existing agent-download and signing tokens — zero new code patterns | +| Per-signer field filtering | Re-parsing the JSONB field array with custom logic | `field.signerEmail === tokenRow.signerEmail` with null-path fallback | One-line filter on the array already fetched from DB | + +**Key insight:** Every pattern needed in this phase already exists in the codebase. The rewrite is wiring existing patterns into new execution paths, not inventing new patterns. + +--- + +## Common Pitfalls + +### Pitfall 1: Re-fetching doc inside the POST handler for accumulator path + +**What goes wrong:** The current POST handler fetches `doc` at step 5 (after the atomic claim). For the accumulator, the `signedFilePath` value at the time of this fetch may be stale — another signer who submitted concurrently may have updated it between the token claim and this fetch. + +**How to avoid:** Re-fetch the document specifically for `signedFilePath` AFTER acquiring the advisory lock (if using transactions) or accept that with unique JTI-keyed partial paths, concurrent writes to different partials don't interfere. The advisory lock approach with a re-fetch inside the lock is safer. + +**Warning signs:** Two signers submit simultaneously; the second signer's output is based on `preparedFilePath` instead of the first signer's partial. + +### Pitfall 2: completionTriggeredAt guard must fire AFTER accumulate, not before + +**What goes wrong:** If the completion guard runs before the current signer's PDF is embedded, the winner may claim completion but then fail during PDF embedding — leaving the document in a `completionTriggeredAt IS NOT NULL` state with no final PDF. + +**How to avoid:** Order the steps: (1) token claim, (2) accumulate PDF for this signer, (3) update signedFilePath, (4) count remaining tokens, (5) only if zero: attempt completionTriggeredAt claim, (6) only if won: compute final hash, update status to Signed, send notifications. + +### Pitfall 3: date field scoping in multi-signer mode + +**What goes wrong:** Current code at line 177-205 stamps ALL date fields. With two signers each having their own date fields, Signer A's handler would stamp Signer B's date fields with Signer A's timestamp. + +**How to avoid:** Filter date fields by `field.signerEmail === tokenRow.signerEmail` before stamping (D-09). For legacy null-signer tokens, keep the existing all-fields behavior. + +**Warning signs:** Both signers show the same date on their respective date fields. Date fields show the timestamp of the first signer who completed, not the second. + +### Pitfall 4: signaturesWithCoords filter must also be signer-scoped + +**What goes wrong:** Lines 210-229 of the current handler build `signaturesWithCoords` from ALL `client-signature` and `initials` fields. In multi-signer mode, this loop would try to find a submitted signature for Signer B's fields when processing Signer A's POST — and throw `Missing signature for field ${field.id}`. + +**How to avoid:** Before building `signaturesWithCoords`, filter `signableFields` to only fields where `field.signerEmail === tokenRow.signerEmail` (same filter as GET handler D-04). For legacy null-signer, use all client-visible fields (existing behavior). + +**Warning signs:** POST handler returns 500 with "Missing signature for field X" where X is another signer's field. + +### Pitfall 5: NEXT_PUBLIC_BASE_URL is baked at build time + +**What goes wrong:** `send/route.ts` line 35: `const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'`. The `NEXT_PUBLIC_` prefix causes Next.js to inline this value at build time. In Docker production, setting this env var at runtime has no effect — the string `localhost:3000` is already in the compiled bundle. + +**How to avoid:** The send route already has this bug in the legacy path. Phase 15's rewrite should rename to `APP_BASE_URL` (no `NEXT_PUBLIC_` prefix) in the send route. (Full Docker fix is Phase 17, but fixing the variable name here prevents the bug from spreading.) + +**Warning signs:** Signing emails are sent but the link points to `localhost:3000` instead of the production domain. + +### Pitfall 6: signedFilePath non-null during 'Sent' status — code that assumes signedFilePath means 'Signed' + +**What goes wrong:** The accumulate pattern updates `signedFilePath` after each intermediate signer. Any code that reads `doc.signedFilePath` and treats it as "final" (e.g., download routes, dashboard) will see partial PDFs as downloadable before all signers complete. + +**How to avoid:** The download endpoint at `GET /api/documents/[id]/download` must continue to check `doc.status === 'Signed'` before serving the file. The new signer download route `GET /api/sign/download/[token]` also checks status. `signedFilePath` alone is not a completion signal — only `status === 'Signed'` is. + +### Pitfall 7: Agent notification email fetch uses `with: { client: }` relation — requires assignedClientId + +**What goes wrong:** The existing fire-and-forget at lines 267-286 uses `with: { client: { columns: { name: true } } }` which joins on `documents.clientId`. For multi-signer documents, the relevant "client" is a list of signers, not a single `clients` row. The join result may have no `name` if the signer emails don't match a `clients` record. + +**How to avoid:** For the agent notification at completion, pass the signer list (count) rather than a client name. Or use a fallback: `freshDoc?.client?.name ?? 'Multiple signers'`. The agent email already exists and just needs a slight wording change for multi-signer context. + +--- + +## Code Examples + +### POST Sign Handler — New Completion Sequence + +```typescript +// Source: CONTEXT.md D-07, PITFALLS.md Pitfall 1/2, ARCHITECTURE.md Part 2 + +// STEP: After atomic token claim succeeds (existing lines 127-135 unchanged) + +// Fetch tokenRow to get signerEmail for scoped field operations +const tokenRow = await db.query.signingTokens.findFirst({ + where: eq(signingTokens.jti, payload.jti), +}); +const signerEmail = tokenRow?.signerEmail ?? null; + +// STEP: Date stamp — scoped to this signer's date fields (D-09) +const dateFields = (doc.signatureFields ?? []).filter((f) => { + if (!isClientVisibleField(f)) return false; + if (getFieldType(f) !== 'date') return false; + if (signerEmail !== null) return f.signerEmail === signerEmail; + return true; // legacy: stamp all date fields +}); + +// STEP: signaturesWithCoords — scoped to this signer's signable fields (Pitfall 4) +const signableFields = (doc.signatureFields ?? []).filter((f) => { + const t = getFieldType(f); + if (t !== 'client-signature' && t !== 'initials') return false; + if (signerEmail !== null) return f.signerEmail === signerEmail; + return isClientVisibleField(f); // legacy +}); + +// STEP: Determine working PDF (accumulate — D-10) +const freshDoc = await db.query.documents.findFirst({ + where: eq(documents.id, payload.documentId), + columns: { signedFilePath: true, preparedFilePath: true }, +}); +const workingRelPath = freshDoc?.signedFilePath ?? freshDoc?.preparedFilePath!; +const workingAbsPath = path.join(UPLOADS_DIR, workingRelPath); + +// Per-signer partial output path +const partialRelPath = freshDoc!.preparedFilePath!.replace( + /_prepared\.pdf$/, + `_partial_${payload.jti}.pdf` +); +const partialAbsPath = path.join(UPLOADS_DIR, partialRelPath); + +// Path traversal guards for both paths +if (!workingAbsPath.startsWith(UPLOADS_DIR) || !partialAbsPath.startsWith(UPLOADS_DIR)) { + return NextResponse.json({ error: 'forbidden' }, { status: 403 }); +} + +// STEP: Embed (date-stamped source → partial output) +const inputPath = dateStampedPath !== workingAbsPath ? dateStampedPath : workingAbsPath; +await embedSignatureInPdf(inputPath, partialAbsPath, signaturesWithCoords); +// NOTE: embedSignatureInPdf returns pdfHash — only store it at completion (final signer) + +// STEP: Update working copy +await db.update(documents) + .set({ signedFilePath: partialRelPath }) + .where(eq(documents.id, payload.documentId)); + +// STEP: Count remaining unclaimed tokens +const remaining = await db + .select({ count: sql`count(*)::int` }) + .from(signingTokens) + .where(and( + eq(signingTokens.documentId, payload.documentId), + isNull(signingTokens.usedAt) + )); +const allDone = remaining[0].count === 0; + +if (!allDone) { + await logAuditEvent({ documentId: payload.documentId, eventType: 'signature_submitted', + ipAddress: ip, userAgent: ua, metadata: { signerEmail } }); + return NextResponse.json({ ok: true }); +} + +// STEP: Race for completion (D-07 step 3) +const won = await db + .update(documents) + .set({ completionTriggeredAt: now }) + .where(and( + eq(documents.id, payload.documentId), + isNull(documents.completionTriggeredAt) + )) + .returning({ id: documents.id }); + +if (won.length === 0) { + // Another handler won — still success for this signer + await logAuditEvent({ documentId: payload.documentId, eventType: 'signature_submitted', + ipAddress: ip, userAgent: ua, metadata: { signerEmail } }); + return NextResponse.json({ ok: true }); +} + +// STEP: Winner — finalize (D-07 step 4) +const finalHash = await hashFile(partialAbsPath); // or re-hash via hashFile from embed-signature +await db.update(documents) + .set({ + status: 'Signed', + signedAt: now, + signedFilePath: partialRelPath, // already set, but confirm + pdfHash: finalHash, + }) + .where(eq(documents.id, payload.documentId)); + +// Fire-and-forget: agent notification + all-signers completion emails (D-12) +// ... +``` + +### GET /api/sign/download/[token] — New Public Download Route + +```typescript +// Source: CONTEXT.md D-11, token.ts createAgentDownloadToken pattern +import { verifySignerDownloadToken } from '@/lib/signing/token'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params; + + let documentId: string; + try { + ({ documentId } = await verifySignerDownloadToken(token)); + } catch { + return new Response('Invalid or expired download link', { status: 401 }); + } + + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, documentId), + columns: { signedFilePath: true, status: true, name: true }, + }); + + if (!doc || doc.status !== 'Signed' || !doc.signedFilePath) { + return new Response('Document not yet complete', { status: 404 }); + } + + const absPath = path.join(UPLOADS_DIR, doc.signedFilePath); + if (!absPath.startsWith(UPLOADS_DIR)) { + return new Response('Forbidden', { status: 403 }); + } + + const fileBytes = await readFile(absPath); + return new Response(fileBytes, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${doc.name}-signed.pdf"`, + }, + }); +} +``` + +### sendSignerCompletionEmail — New Function in signing-mailer.tsx + +```typescript +// Source: signing-mailer.tsx existing pattern; CONTEXT.md D-12 +export async function sendSignerCompletionEmail(opts: { + to: string; + signerName?: string; + documentName: string; + downloadUrl: string; + expiresAt: Date; +}): Promise { + const transporter = createTransporter(); + await transporter.sendMail({ + from: '"Teressa Copeland Homes" ', + to: opts.to, + subject: `Signed copy ready: ${opts.documentName}`, + text: [ + `All parties have signed "${opts.documentName}".`, + `Download your copy here (link expires ${opts.expiresAt.toLocaleDateString('en-US')}):`, + opts.downloadUrl, + ].join('\n\n'), + }); +} +``` + +--- + +## Files Being Changed (from CONTEXT.md canonical refs) + +| File | Change Type | What Changes | +|------|-------------|--------------| +| `src/lib/signing/token.ts` | Extend | Add optional `signerEmail?` param to `createSigningToken`; add `createSignerDownloadToken` / `verifySignerDownloadToken` | +| `src/app/api/documents/[id]/send/route.ts` | Rewrite | Legacy fallback (D-01) + multi-signer token loop (D-02/D-03) | +| `src/app/api/sign/[token]/route.ts` | Rewrite | GET: signer-aware field filter (D-04/D-05/D-06); POST: accumulate + completion guard (D-07 through D-09) | +| `src/lib/signing/signing-mailer.tsx` | Add function | `sendSignerCompletionEmail` (D-12) | +| `src/app/api/sign/download/[token]/route.ts` | New file | Public signer download route (D-11) | + +--- + +## State of the Art (Phase 14 outputs confirmed in schema.ts) + +The following are confirmed present in the codebase from Phase 14 inspection: + +| Column / Type | Location | Confirmed | +|---------------|----------|-----------| +| `signingTokens.signerEmail` (nullable TEXT) | `schema.ts` line 140 | YES | +| `documents.signers` (nullable JSONB `DocumentSigner[]`) | `schema.ts` line 113 | YES | +| `documents.completionTriggeredAt` (nullable TIMESTAMP) | `schema.ts` line 115 | YES | +| `DocumentSigner` interface `{ email: string; color: string }` | `schema.ts` line 87-90 | YES | +| `getSignerEmail(field, fallback)` helper | `schema.ts` line 49-51 | YES | +| `SignatureFieldData.signerEmail?` | `schema.ts` line 21 | YES | + +Note: `DocumentSigner` has `{ email, color }` — NO `tokenJti` or `signedAt` fields. The ARCHITECTURE.md described these fields but they were NOT added in Phase 14 (schema.ts is authoritative). The send route cannot store `tokenJti` back into `doc.signers[]` unless Phase 15 adds an UPDATE after token creation. This is a discretion area — the planner should decide whether to update `doc.signers[n].tokenJti` at send time. + +Also note: `auditEventTypeEnum` in `schema.ts` lines 126-133 still only has the original 6 values (`document_prepared`, `email_sent`, `link_opened`, `document_viewed`, `signature_submitted`, `pdf_hash_computed`). The new audit event types (`signer_email_sent`, `signer_signed`, `document_completed`) from the ARCHITECTURE.md research were NOT added in Phase 14. Phase 15 must either (a) use existing `email_sent` / `signature_submitted` events with `metadata: { signerEmail }` to avoid a schema migration, or (b) include a tiny migration to add the new enum values. Since CONTEXT.md says "No schema migrations" for Phase 15, approach (a) — use existing event types with metadata — is the correct path. + +--- + +## Environment Availability + +Step 2.6: SKIPPED — this phase is purely code changes to existing routes with no new external dependencies. All required tools (Node.js, the DB connection via Neon, SMTP) are already operational. + +--- + +## Validation Architecture + +`workflow.nyquist_validation` is not set in `.planning/config.json` — treating as enabled. + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Jest + ts-jest | +| Config file | None found — `jest.config.ts` does not exist at project root; `@types/jest` and `jest` are devDependencies | +| Quick run command | `cd teressa-copeland-homes && npx jest --testPathPattern=sign` | +| Full suite command | `cd teressa-copeland-homes && npx jest` | + +One test file exists: `src/lib/pdf/__tests__/prepare-document.test.ts` (Y-flip coordinate unit tests, no relation to signing flow). + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| MSIGN-05 | `doc.signers` drives token creation | unit | `npx jest --testPathPattern=send` | No — Wave 0 | +| MSIGN-06 | All tokens created, all emails dispatched in parallel | unit | `npx jest --testPathPattern=send` | No — Wave 0 | +| MSIGN-07 | GET /sign/[token] returns only signer-scoped fields | unit | `npx jest --testPathPattern=sign-get` | No — Wave 0 | +| MSIGN-10 | Agent notification fires only when all tokens claimed | unit | `npx jest --testPathPattern=sign-post` | No — Wave 0 | +| MSIGN-11 | Signer completion emails + download route | unit | `npx jest --testPathPattern=sign-post` | No — Wave 0 | + +### Sampling Rate +- **Per task commit:** `cd teressa-copeland-homes && npx jest --testPathPattern=signing` +- **Per wave merge:** `cd teressa-copeland-homes && npx jest` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `src/app/api/documents/[id]/send/__tests__/send-route.test.ts` — covers MSIGN-05, MSIGN-06 (mock `createSigningToken`, `sendSigningRequestEmail`; verify Promise.all dispatch count) +- [ ] `src/app/api/sign/[token]/__tests__/sign-get.test.ts` — covers MSIGN-07 (mock DB rows; verify filtered vs unfiltered field arrays) +- [ ] `src/app/api/sign/[token]/__tests__/sign-post.test.ts` — covers MSIGN-10, MSIGN-11 (mock atomic claim, count query, completionTriggeredAt race) +- [ ] `jest.config.ts` — no jest config file exists at project root; ts-jest is installed but unconfigured + +--- + +## Open Questions + +1. **Advisory lock vs unique partial paths — which approach for accumulate?** + - What we know: ARCHITECTURE.md recommends `pg_advisory_xact_lock`. The Context (D-10) uses `signedFilePath` as a serial working copy, implying the lock is needed to prevent read-modify-write races. + - What's unclear: Whether Drizzle's `db.execute(sql`...`)` can run advisory lock SQL without being inside an explicit transaction — `pg_advisory_xact_lock` requires a transaction to be meaningful. + - Recommendation: Use unique JTI-keyed output paths for partial PDFs (no lock needed for writes). Use `completionTriggeredAt` guard (already proven) for the completion race. Advisory lock only needed if the final completion handler reads and processes intermediate partials serially — which it doesn't in the D-10 design. + +2. **`DocumentSigner` lacks `tokenJti` — should send route update the signers array?** + - What we know: `schema.ts` `DocumentSigner = { email, color }`. ARCHITECTURE.md described `tokenJti` as a field but it was not implemented in Phase 14. + - What's unclear: Whether the planner should add a DB update to store `tokenJti` on each signer after token creation (useful for resend functionality later). + - Recommendation: Skip for Phase 15. Phase 16 (dashboard per-signer status) can query `signingTokens` by `signerEmail + documentId` to find the token — no denormalization needed. Add as discretion item in PLAN.md notes. + +3. **`APP_BASE_URL` rename — is Phase 15 the right place?** + - What we know: `send/route.ts` uses `NEXT_PUBLIC_BASE_URL` which bakes at build time (Pitfall 5). Phase 17 is the Docker phase. The variable is read only in server-side route handlers — it should never have the `NEXT_PUBLIC_` prefix. + - What's unclear: Whether to fix this in Phase 15 (touching send/route.ts anyway) or defer to Phase 17. + - Recommendation: Fix it in Phase 15 since the route is being rewritten. Change `process.env.NEXT_PUBLIC_BASE_URL` to `process.env.APP_BASE_URL` in send/route.ts and note in Phase 17 that the env var name has already been updated. + +--- + +## Project Constraints (from AGENTS.md / CLAUDE.md) + +`CLAUDE.md` at `teressa-copeland-homes/CLAUDE.md` reads `@AGENTS.md`. `AGENTS.md` states: + +> This is NOT the Next.js you know. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +**Implication for planning:** Implementation tasks must include a directive to read `node_modules/next/dist/docs/` before writing any new route files. Specifically relevant for the new `GET /api/sign/download/[token]` route and any changes to the App Router route handler signatures. + +--- + +## Sources + +### Primary (HIGH confidence) +- Direct codebase inspection: `src/app/api/sign/[token]/route.ts` — exact current implementation of GET and POST handlers +- Direct codebase inspection: `src/app/api/documents/[id]/send/route.ts` — exact current implementation +- Direct codebase inspection: `src/lib/signing/token.ts` — JWT utility functions, exact signatures +- Direct codebase inspection: `src/lib/signing/signing-mailer.tsx` — email utility, `createTransporter` pattern +- Direct codebase inspection: `src/lib/signing/embed-signature.ts` — `embedSignatureInPdf` signature and atomic rename pattern +- Direct codebase inspection: `src/lib/db/schema.ts` — Phase 14 schema additions confirmed present +- `.planning/phases/15-multi-signer-backend/15-CONTEXT.md` — all locked implementation decisions D-01 through D-12 + +### Secondary (MEDIUM confidence) +- `.planning/research/ARCHITECTURE.md` — multi-signer data flow, advisory lock pattern, completion sequence +- `.planning/research/PITFALLS.md` — first-signer-wins bug analysis (line references verified against actual code), race condition pattern + +### Notes +- `DocumentSigner` interface discrepancy noted: ARCHITECTURE.md described `tokenJti` and `signedAt` fields; actual `schema.ts` has only `{ email, color }`. Code references in RESEARCH.md use the authoritative schema.ts definition. +- `auditEventTypeEnum` discrepancy noted: ARCHITECTURE.md described new enum values; actual schema.ts has only the original 6. Phase 15 uses existing `email_sent`/`signature_submitted` events with `metadata` to avoid requiring a schema migration. + +--- + +## Metadata + +**Confidence breakdown:** +- Current code structure: HIGH — all three routes and utilities read directly +- Schema completeness (Phase 14 output): HIGH — schema.ts verified line by line +- Completion guard pattern: HIGH — identical pattern already exists for token claim +- Accumulate PDF strategy: HIGH — `embedSignatureInPdf` verified to accept any input path +- signer-download JWT: HIGH — identical to existing `createAgentDownloadToken` pattern +- Advisory lock recommendation: MEDIUM — `db.execute(sql`...`)` for raw SQL not verified against this Next.js version; alternative (unique output paths) is safer and equally correct + +**Research date:** 2026-04-03 +**Valid until:** 2026-05-03 (schema is stable; JWT library unlikely to change)