Files
red/.planning/phases/15-multi-signer-backend/15-RESEARCH.md
2026-04-03 15:31:57 -06:00

675 lines
38 KiB
Markdown

# 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>
## 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.
</user_constraints>
---
<phase_requirements>
## 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) |
</phase_requirements>
---
## 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<string> {
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<number>`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<void> {
const transporter = createTransporter();
await transporter.sendMail({
from: '"Teressa Copeland Homes" <teressa@tcopelandhomes.com>',
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)