diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 695eac8..49d4a4e 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -333,7 +333,12 @@ Plans:
3. The POST /api/sign/[token] handler rejects any field submission where the field's signerEmail does not match the token's signerEmail (server-enforced ownership)
4. When the last signer submits, `completionTriggeredAt` is claimed atomically — only one handler triggers final PDF assembly regardless of concurrent submissions
5. Agent receives a notification email and all signers receive the final merged PDF link when all signing tokens for the document are claimed
-**Plans**: TBD
+**Plans**: 3 plans
+
+Plans:
+- [ ] 15-01-PLAN.md — Token utility extensions (signerEmail param, signer-download JWT), sendSignerCompletionEmail mailer, public signer download route
+- [ ] 15-02-PLAN.md — Send route rewrite: multi-signer token loop with Promise.all dispatch, legacy single-signer fallback, APP_BASE_URL rename
+- [ ] 15-03-PLAN.md — Sign handler rewrite: GET signer-filtered fields, POST signer-scoped operations + accumulate PDF + atomic completion + notifications
**UI hint**: no
### Phase 16: Multi-Signer UI
@@ -345,7 +350,12 @@ Plans:
2. Each field placed in FieldPlacer has a signer assignment dropdown; selecting a signer changes the field's color to that signer's assigned color
3. If the agent clicks Send with any client-facing field (signature, initials, date, text) having no signer assigned, the send is blocked and a clear error message identifies the unassigned fields
4. The dashboard document detail view shows a per-signer completion row for each signer (who has signed, who hasn't, with timestamps)
-**Plans**: TBD
+**Plans**: 3 plans
+
+Plans:
+- [ ] 15-01-PLAN.md — Token utility extensions (signerEmail param, signer-download JWT), sendSignerCompletionEmail mailer, public signer download route
+- [ ] 15-02-PLAN.md — Send route rewrite: multi-signer token loop with Promise.all dispatch, legacy single-signer fallback, APP_BASE_URL rename
+- [ ] 15-03-PLAN.md — Sign handler rewrite: GET signer-filtered fields, POST signer-scoped operations + accumulate PDF + atomic completion + notifications
**UI hint**: yes
### Phase 17: Docker Deployment
@@ -358,7 +368,12 @@ Plans:
3. The `APP_BASE_URL` variable (renamed from `NEXT_PUBLIC_BASE_URL`) is injected at container runtime — signing link URLs in emails contain the correct production domain, not localhost
4. Uploaded PDF files written inside the container persist after `docker compose down && docker compose up` (named Docker volume mounted at /app/uploads)
5. The Docker image uses `node:20-slim` (Debian-based) — `@napi-rs/canvas` native binary loads without errors at container startup
-**Plans**: TBD
+**Plans**: 3 plans
+
+Plans:
+- [ ] 15-01-PLAN.md — Token utility extensions (signerEmail param, signer-download JWT), sendSignerCompletionEmail mailer, public signer download route
+- [ ] 15-02-PLAN.md — Send route rewrite: multi-signer token loop with Promise.all dispatch, legacy single-signer fallback, APP_BASE_URL rename
+- [ ] 15-03-PLAN.md — Sign handler rewrite: GET signer-filtered fields, POST signer-scoped operations + accumulate PDF + atomic completion + notifications
**UI hint**: no
## Progress
@@ -384,6 +399,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 12.1. Per-Field Text Editing and Quick-Fill (INSERTED) | v1.1 | 2/2 | Complete | 2026-03-21 |
| 13. AI Field Placement and Pre-fill | v1.1 | 3/4 | In Progress | - |
| 14. Multi-Signer Schema | v1.2 | 1/1 | Complete | 2026-04-03 |
-| 15. Multi-Signer Backend | v1.2 | 0/TBD | Not started | - |
+| 15. Multi-Signer Backend | v1.2 | 0/3 | Not started | - |
| 16. Multi-Signer UI | v1.2 | 0/TBD | Not started | - |
| 17. Docker Deployment | v1.2 | 0/TBD | Not started | - |
diff --git a/.planning/phases/15-multi-signer-backend/15-01-PLAN.md b/.planning/phases/15-multi-signer-backend/15-01-PLAN.md
new file mode 100644
index 0000000..66a4ebc
--- /dev/null
+++ b/.planning/phases/15-multi-signer-backend/15-01-PLAN.md
@@ -0,0 +1,290 @@
+---
+phase: 15-multi-signer-backend
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - teressa-copeland-homes/src/lib/signing/token.ts
+ - teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
+ - teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts
+autonomous: true
+requirements: [MSIGN-10, MSIGN-11]
+must_haves:
+ truths:
+ - "createSigningToken accepts optional signerEmail and writes it to DB"
+ - "createSignerDownloadToken produces a 72h JWT with purpose signer-download"
+ - "verifySignerDownloadToken validates purpose claim and returns documentId"
+ - "sendSignerCompletionEmail sends a plain-text email with download link"
+ - "GET /api/sign/download/[token] serves the signed PDF for valid signer-download tokens"
+ - "GET /api/sign/download/[token] rejects expired tokens, non-Signed documents, and path traversal"
+ artifacts:
+ - path: "teressa-copeland-homes/src/lib/signing/token.ts"
+ provides: "Extended createSigningToken + signer-download token pair"
+ exports: ["createSigningToken", "createSignerDownloadToken", "verifySignerDownloadToken"]
+ - path: "teressa-copeland-homes/src/lib/signing/signing-mailer.tsx"
+ provides: "Signer completion email"
+ exports: ["sendSignerCompletionEmail"]
+ - path: "teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts"
+ provides: "Public signer download route"
+ exports: ["GET"]
+ key_links:
+ - from: "src/app/api/sign/download/[token]/route.ts"
+ to: "src/lib/signing/token.ts"
+ via: "verifySignerDownloadToken import"
+ pattern: "verifySignerDownloadToken"
+ - from: "src/app/api/sign/download/[token]/route.ts"
+ to: "documents table"
+ via: "db.query.documents.findFirst"
+ pattern: "doc\\.status.*Signed"
+---
+
+
+Create all utility building blocks for multi-signer: extend createSigningToken with optional signerEmail, add signer-download JWT token pair, add sendSignerCompletionEmail mailer function, and create the public signer download route.
+
+Purpose: Plans 02 and 03 depend on these utilities being in place before the send route and sign handlers can be rewritten.
+Output: Extended token.ts, extended signing-mailer.tsx, new download route file.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/15-multi-signer-backend/15-CONTEXT.md
+@.planning/phases/15-multi-signer-backend/15-RESEARCH.md
+
+@teressa-copeland-homes/src/lib/signing/token.ts
+@teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
+@teressa-copeland-homes/src/lib/db/schema.ts
+
+
+
+
+From src/lib/signing/token.ts:
+```typescript
+// Existing — extend, do not break
+export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }>;
+export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }>;
+export async function createAgentDownloadToken(documentId: string): Promise;
+export async function verifyAgentDownloadToken(token: string): Promise<{ documentId: string }>;
+```
+
+From src/lib/signing/signing-mailer.tsx:
+```typescript
+export async function sendSigningRequestEmail(opts: { to: string; clientName?: string; documentName: string; signingUrl: string; expiresAt: Date }): Promise;
+export async function sendAgentNotificationEmail(opts: { clientName: string; documentName: string; signedAt: Date }): Promise;
+```
+
+From src/lib/db/schema.ts:
+```typescript
+export interface SignatureFieldData { id: string; page: number; x: number; y: number; width: number; height: number; type?: SignatureFieldType; signerEmail?: string; }
+export interface DocumentSigner { email: string; color: string; }
+// signingTokens table has: jti, documentId, signerEmail (nullable TEXT), createdAt, expiresAt, usedAt
+```
+
+
+
+
+
+
+ Task 1: Extend token.ts with signerEmail param and signer-download token pair
+ teressa-copeland-homes/src/lib/signing/token.ts
+
+ - teressa-copeland-homes/src/lib/signing/token.ts
+ - teressa-copeland-homes/src/lib/db/schema.ts
+ - teressa-copeland-homes/node_modules/next/dist/docs/ (scan for route handler guidance)
+
+
+ Modify `createSigningToken` to accept an optional second parameter `signerEmail?: string` (per D-03). Add `signerEmail: signerEmail ?? null` to the `db.insert(signingTokens).values({...})` call. The JWT payload is unchanged — signerEmail is stored in DB only, not in the JWT. All existing call sites (`createSigningToken(doc.id)`) continue to compile because the param is optional.
+
+ Add two new functions after the existing `verifyAgentDownloadToken`:
+
+ ```typescript
+ export async function createSignerDownloadToken(documentId: string): Promise {
+ return await new SignJWT({ documentId, purpose: 'signer-download' })
+ .setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt()
+ .setExpirationTime('72h')
+ .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 signer-download token has 72h TTL (longer than agent-download 5m) because signers may not open the completion email immediately. No DB record — same as agent-download tokens. Purpose claim `'signer-download'` distinguishes from all other token types.
+
+
+ cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - grep confirms `signerEmail?: string` in createSigningToken signature: `grep -n "signerEmail.*string" src/lib/signing/token.ts`
+ - grep confirms `signerEmail: signerEmail ?? null` in the insert: `grep -n "signerEmail:" src/lib/signing/token.ts`
+ - grep confirms `createSignerDownloadToken` export: `grep -n "export async function createSignerDownloadToken" src/lib/signing/token.ts`
+ - grep confirms `verifySignerDownloadToken` export: `grep -n "export async function verifySignerDownloadToken" src/lib/signing/token.ts`
+ - grep confirms `purpose: 'signer-download'` in both functions: `grep -c "signer-download" src/lib/signing/token.ts` returns 2
+ - grep confirms `setExpirationTime('72h')` on signer-download token: `grep -A2 "createSignerDownloadToken" src/lib/signing/token.ts | grep "72h"`
+ - `npx tsc --noEmit` passes with zero errors
+
+ createSigningToken accepts optional signerEmail and persists it. createSignerDownloadToken/verifySignerDownloadToken produce and validate 72h JWTs with purpose signer-download. All existing call sites compile unchanged.
+
+
+
+ Task 2: Add sendSignerCompletionEmail to signing-mailer.tsx
+ teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
+
+ - teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
+
+
+ Add `sendSignerCompletionEmail` function after `sendAgentNotificationEmail`. Follow the exact same `createTransporter()` + `transporter.sendMail()` pattern (per D-12). Plain-text email — no React Email template needed for this.
+
+ ```typescript
+ export async function sendSignerCompletionEmail(opts: {
+ to: string;
+ documentName: string;
+ downloadUrl: string;
+ }): 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 signed copy using the link below (expires in 72 hours):',
+ opts.downloadUrl,
+ ].join('\n'),
+ });
+ }
+ ```
+
+ The function accepts `to`, `documentName`, and `downloadUrl`. No `expiresAt` param — the 72h expiry is baked into the text. This keeps the interface simple; the caller constructs the URL from `createSignerDownloadToken`.
+
+
+ cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - grep confirms export: `grep -n "export async function sendSignerCompletionEmail" src/lib/signing/signing-mailer.tsx`
+ - grep confirms opts shape: `grep -A4 "sendSignerCompletionEmail" src/lib/signing/signing-mailer.tsx | grep "downloadUrl"`
+ - grep confirms subject line: `grep "Signed copy ready" src/lib/signing/signing-mailer.tsx`
+ - `npx tsc --noEmit` passes with zero errors
+
+ sendSignerCompletionEmail is exported from signing-mailer.tsx and sends a plain-text email with the signed document download link to a signer.
+
+
+
+ Task 3: Create public signer download route GET /api/sign/download/[token]
+ teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts
+
+ - teressa-copeland-homes/src/lib/signing/token.ts (after Task 1 modifications)
+ - teressa-copeland-homes/src/app/api/sign/[token]/route.ts (existing pattern for path traversal guard)
+ - teressa-copeland-homes/node_modules/next/dist/docs/ (scan for route handler API)
+
+
+ Create a new route file at `src/app/api/sign/download/[token]/route.ts` (per D-11). This is a public route — no auth session required. The route:
+
+ 1. Extracts `token` from dynamic path params (App Router async params pattern: `{ params }: { params: Promise<{ token: string }> }`)
+ 2. Calls `verifySignerDownloadToken(token)` — returns `{ documentId }` or throws
+ 3. On verification failure: returns `new Response('Invalid or expired download link', { status: 401 })`
+ 4. Queries `db.query.documents.findFirst` for `signedFilePath`, `status`, `name` where `id = documentId`
+ 5. Guards: `!doc || doc.status !== 'Signed' || !doc.signedFilePath` returns 404 "Document not yet complete" (Pitfall 6 — signedFilePath alone is not a completion signal)
+ 6. Builds absolute path: `path.join(UPLOADS_DIR, doc.signedFilePath)`
+ 7. Path traversal guard: `!absPath.startsWith(UPLOADS_DIR)` returns 403
+ 8. Reads file with `readFile(absPath)`
+ 9. Returns `new Response(fileBytes, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename="DOC_NAME-signed.pdf"' } })`
+
+ Full implementation:
+
+ ```typescript
+ import { NextRequest } from 'next/server';
+ import { verifySignerDownloadToken } from '@/lib/signing/token';
+ import { db } from '@/lib/db';
+ import { documents } from '@/lib/db/schema';
+ import { eq } from 'drizzle-orm';
+ 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"`,
+ },
+ });
+ }
+ ```
+
+
+ cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - File exists: `test -f src/app/api/sign/download/\[token\]/route.ts && echo exists`
+ - grep confirms verifySignerDownloadToken import: `grep "verifySignerDownloadToken" src/app/api/sign/download/\[token\]/route.ts`
+ - grep confirms status check: `grep "doc.status.*Signed" src/app/api/sign/download/\[token\]/route.ts`
+ - grep confirms path traversal guard: `grep "startsWith(UPLOADS_DIR)" src/app/api/sign/download/\[token\]/route.ts`
+ - grep confirms Content-Disposition: `grep "Content-Disposition" src/app/api/sign/download/\[token\]/route.ts`
+ - grep confirms no auth import (public route): `grep -c "import.*auth" src/app/api/sign/download/\[token\]/route.ts` returns 0
+ - `npx tsc --noEmit` passes with zero errors
+
+ Public signer download route serves the signed PDF for valid signer-download tokens. Rejects expired/invalid tokens (401), incomplete documents (404), and path traversal (403). No auth session required.
+
+
+
+
+
+All three files compile: `cd teressa-copeland-homes && npx tsc --noEmit`
+Token utility has 3 token types: signing, agent-download, signer-download (grep for 'purpose' in token.ts yields 3+ distinct strings)
+Mailer has 3 email functions: sendSigningRequestEmail, sendAgentNotificationEmail, sendSignerCompletionEmail
+Download route exists at the correct App Router path and imports from token.ts
+
+
+
+1. `createSigningToken(docId, 'alice@example.com')` compiles and persists signerEmail to DB
+2. `createSigningToken(docId)` still compiles (backward compatible — no signerEmail written)
+3. `createSignerDownloadToken(docId)` returns a JWT string; `verifySignerDownloadToken(jwt)` returns `{ documentId }`
+4. `sendSignerCompletionEmail({ to, documentName, downloadUrl })` compiles and follows established mailer pattern
+5. `GET /api/sign/download/[token]` route file exists, compiles, and guards against all failure modes
+6. `npx tsc --noEmit` passes with zero errors across the entire project
+
+
+
diff --git a/.planning/phases/15-multi-signer-backend/15-02-PLAN.md b/.planning/phases/15-multi-signer-backend/15-02-PLAN.md
new file mode 100644
index 0000000..a6d901c
--- /dev/null
+++ b/.planning/phases/15-multi-signer-backend/15-02-PLAN.md
@@ -0,0 +1,260 @@
+---
+phase: 15-multi-signer-backend
+plan: 02
+type: execute
+wave: 2
+depends_on: [15-01]
+files_modified:
+ - teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts
+autonomous: true
+requirements: [MSIGN-05, MSIGN-06]
+must_haves:
+ truths:
+ - "When documents.signers is populated, one signing token is created per signer with that signer's email"
+ - "All signing request emails are dispatched in parallel via Promise.all"
+ - "When documents.signers is null/empty, the existing single-signer behavior is preserved exactly"
+ - "The signing URL uses APP_BASE_URL (not NEXT_PUBLIC_BASE_URL)"
+ - "Document status is set to Sent after all emails dispatch successfully"
+ - "Each signer gets an audit event email_sent with metadata.signerEmail"
+ artifacts:
+ - path: "teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts"
+ provides: "Multi-signer send route with legacy fallback"
+ exports: ["POST"]
+ key_links:
+ - from: "src/app/api/documents/[id]/send/route.ts"
+ to: "src/lib/signing/token.ts"
+ via: "createSigningToken(doc.id, signer.email)"
+ pattern: "createSigningToken.*signerEmail"
+ - from: "src/app/api/documents/[id]/send/route.ts"
+ to: "src/lib/signing/signing-mailer.tsx"
+ via: "sendSigningRequestEmail in Promise.all loop"
+ pattern: "Promise\\.all.*sendSigningRequestEmail"
+---
+
+
+Rewrite the send route to loop over document signers, create one token per signer, and dispatch all signing emails in parallel. Preserve exact legacy single-signer behavior when signers is null/empty. Fix NEXT_PUBLIC_BASE_URL to APP_BASE_URL.
+
+Purpose: This is the entry point for multi-signer flow — without this, documents can only be sent to one recipient.
+Output: Rewritten send/route.ts with multi-signer token loop + legacy fallback.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/15-multi-signer-backend/15-CONTEXT.md
+@.planning/phases/15-multi-signer-backend/15-RESEARCH.md
+@.planning/phases/15-multi-signer-backend/15-01-SUMMARY.md
+
+@teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts
+@teressa-copeland-homes/src/lib/signing/token.ts
+@teressa-copeland-homes/src/lib/db/schema.ts
+
+
+
+```typescript
+export async function createSigningToken(documentId: string, signerEmail?: string): Promise<{ token: string; jti: string; expiresAt: Date }>;
+```
+
+
+```typescript
+export interface DocumentSigner { email: string; color: string; }
+// documents.signers is jsonb().$type() — nullable
+```
+
+
+```typescript
+export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> })
+```
+
+
+```typescript
+import { createSigningToken } from '@/lib/signing/token';
+import { sendSigningRequestEmail } from '@/lib/signing/signing-mailer';
+import { logAuditEvent } from '@/lib/signing/audit';
+import { documents, clients } from '@/lib/db/schema';
+```
+
+
+
+
+
+
+ Task 1: Rewrite send route with multi-signer token loop and legacy fallback
+ teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts
+
+ - teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts
+ - teressa-copeland-homes/src/lib/signing/token.ts (confirm signerEmail param exists from Plan 01)
+ - teressa-copeland-homes/src/lib/signing/signing-mailer.tsx (confirm sendSigningRequestEmail signature)
+ - teressa-copeland-homes/src/lib/db/schema.ts (confirm DocumentSigner type, documents.signers)
+ - teressa-copeland-homes/node_modules/next/dist/docs/ (scan for route handler API)
+
+
+ Rewrite the POST handler in `src/app/api/documents/[id]/send/route.ts`. The structure:
+
+ **Keep unchanged:** auth guard, doc fetch, preparedFilePath guard, status === 'Signed' guard, error boundary try/catch.
+
+ **Change 1 — APP_BASE_URL (Pitfall 5, per quality gate):** Replace `process.env.NEXT_PUBLIC_BASE_URL` with `process.env.APP_BASE_URL` on the baseUrl line. Keep `?? 'http://localhost:3000'` fallback.
+
+ **Change 2 — Branching logic (D-01, D-02):** After the doc fetch and guards, branch on `doc.signers && doc.signers.length > 0`:
+
+ **Multi-signer path (D-02):**
+ ```typescript
+ const signers = doc.signers as DocumentSigner[];
+ const baseUrl = process.env.APP_BASE_URL ?? 'http://localhost:3000';
+
+ await Promise.all(
+ 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 },
+ });
+ })
+ );
+ ```
+
+ Note: `sendSigningRequestEmail` has an optional `clientName` param. In multi-signer mode, we do NOT have a per-signer name (DocumentSigner is `{ email, color }` only). Omitting `clientName` is fine — the email template handles it gracefully.
+
+ **Legacy single-signer path (D-01):** Keep the existing logic verbatim — resolve clientId, fetch client, create single token (no signerEmail), send email with client.name, log audit event. Only change is `APP_BASE_URL` instead of `NEXT_PUBLIC_BASE_URL`.
+
+ **After branch:** Both paths converge — update status to 'Sent' if currently 'Draft' (existing logic, unchanged). Return `{ ok: true, expiresAt }` — for multi-signer, use the last signer's expiresAt or just return `ok: true` without expiresAt (all tokens have the same 72h TTL anyway).
+
+ **Import changes:** Add `DocumentSigner` import from schema if not already imported.
+
+ **Error handling (Claude's Discretion):** Individual signer email failure should not block the entire send. Wrap each signer's map callback in try/catch — if one email fails, the others still send. Collect errors and return 207 Multi-Status if partial failure, or 502 if all fail. Alternatively, keep it simple: let Promise.all fail if any email fails (current behavior for single signer). Decision: keep Promise.all as-is for now (simple, consistent with existing pattern). If one email fails, the entire send fails and the agent retries.
+
+ Complete rewrite:
+
+ ```typescript
+ import { NextResponse } from 'next/server';
+ import { auth } from '@/lib/auth';
+ import { db } from '@/lib/db';
+ import { documents, clients, DocumentSigner } from '@/lib/db/schema';
+ import { eq } from 'drizzle-orm';
+ import { createSigningToken } from '@/lib/signing/token';
+ import { sendSigningRequestEmail } from '@/lib/signing/signing-mailer';
+ import { logAuditEvent } from '@/lib/signing/audit';
+
+ export async function POST(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> }
+ ) {
+ const session = await auth();
+ if (!session) return new Response('Unauthorized', { status: 401 });
+
+ const { id } = await params;
+
+ try {
+ const doc = await db.query.documents.findFirst({
+ where: eq(documents.id, id),
+ });
+ if (!doc) return NextResponse.json({ error: 'Document not found' }, { status: 404 });
+ if (!doc.preparedFilePath)
+ return NextResponse.json({ error: 'Document not yet prepared' }, { status: 422 });
+ if (doc.status === 'Signed')
+ return NextResponse.json({ error: 'Already signed' }, { status: 409 });
+
+ const baseUrl = process.env.APP_BASE_URL ?? 'http://localhost:3000';
+
+ if (doc.signers && (doc.signers as DocumentSigner[]).length > 0) {
+ // ── Multi-signer path (D-02) ──
+ const signers = doc.signers as DocumentSigner[];
+ await Promise.all(
+ 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 (D-01) ──
+ const clientId = doc.assignedClientId ?? doc.clientId;
+ const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
+ if (!client) return NextResponse.json({ error: 'Client not found' }, { status: 422 });
+
+ const { token, expiresAt } = await createSigningToken(doc.id);
+ const signingUrl = `${baseUrl}/sign/${token}`;
+ await sendSigningRequestEmail({
+ to: client.email,
+ clientName: client.name,
+ documentName: doc.name,
+ signingUrl,
+ expiresAt,
+ });
+ await logAuditEvent({ documentId: doc.id, eventType: 'email_sent' });
+ }
+
+ // Update status to Sent (skip if already Sent or Signed to avoid downgrade)
+ if (doc.status === 'Draft') {
+ await db
+ .update(documents)
+ .set({ status: 'Sent', sentAt: new Date() })
+ .where(eq(documents.id, id));
+ }
+
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ console.error('[send] error:', err);
+ return NextResponse.json({ error: 'Failed to send signing email' }, { status: 502 });
+ }
+ }
+ ```
+
+
+ cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - grep confirms APP_BASE_URL: `grep "APP_BASE_URL" src/app/api/documents/\[id\]/send/route.ts`
+ - grep confirms NO NEXT_PUBLIC_BASE_URL: `grep -c "NEXT_PUBLIC_BASE_URL" src/app/api/documents/\[id\]/send/route.ts` returns 0
+ - grep confirms multi-signer branch: `grep "doc.signers" src/app/api/documents/\[id\]/send/route.ts`
+ - grep confirms Promise.all: `grep "Promise.all" src/app/api/documents/\[id\]/send/route.ts`
+ - grep confirms createSigningToken with signerEmail: `grep "createSigningToken(doc.id, signer.email)" src/app/api/documents/\[id\]/send/route.ts`
+ - grep confirms legacy path still uses createSigningToken without signerEmail: `grep "createSigningToken(doc.id)" src/app/api/documents/\[id\]/send/route.ts`
+ - grep confirms metadata with signerEmail in audit: `grep "signerEmail: signer.email" src/app/api/documents/\[id\]/send/route.ts`
+ - grep confirms DocumentSigner import: `grep "DocumentSigner" src/app/api/documents/\[id\]/send/route.ts`
+ - `npx tsc --noEmit` passes with zero errors
+
+ Send route creates one token per signer with signerEmail written to DB, dispatches all emails in parallel. Legacy single-signer path preserved unchanged. APP_BASE_URL replaces NEXT_PUBLIC_BASE_URL.
+
+
+
+
+
+`cd teressa-copeland-homes && npx tsc --noEmit` passes
+Send route has both multi-signer and legacy branches
+APP_BASE_URL used instead of NEXT_PUBLIC_BASE_URL
+
+
+
+1. Multi-signer documents: `doc.signers = [{email: 'a@x.com', color: '#f00'}, {email: 'b@x.com', color: '#0f0'}]` results in 2 tokens created, 2 emails sent, 2 audit events
+2. Legacy documents: `doc.signers = null` results in 1 token (no signerEmail), 1 email to assigned client
+3. `APP_BASE_URL` is the env var used for signing URLs (not `NEXT_PUBLIC_BASE_URL`)
+4. `npx tsc --noEmit` passes
+
+
+
diff --git a/.planning/phases/15-multi-signer-backend/15-03-PLAN.md b/.planning/phases/15-multi-signer-backend/15-03-PLAN.md
new file mode 100644
index 0000000..b2fa257
--- /dev/null
+++ b/.planning/phases/15-multi-signer-backend/15-03-PLAN.md
@@ -0,0 +1,495 @@
+---
+phase: 15-multi-signer-backend
+plan: 03
+type: execute
+wave: 2
+depends_on: [15-01]
+files_modified:
+ - teressa-copeland-homes/src/app/api/sign/[token]/route.ts
+autonomous: true
+requirements: [MSIGN-07, MSIGN-10, MSIGN-11]
+must_haves:
+ truths:
+ - "GET handler returns only fields where field.signerEmail matches tokenRow.signerEmail"
+ - "GET handler returns all isClientVisibleField fields for legacy null-signer tokens"
+ - "POST handler scopes date field stamping to this signer's date fields only"
+ - "POST handler scopes signaturesWithCoords to this signer's signable fields only"
+ - "POST handler reads from signedFilePath (if exists) or preparedFilePath as working PDF"
+ - "POST handler writes to a JTI-keyed partial path and updates signedFilePath"
+ - "POST handler checks remaining unclaimed tokens before attempting completion"
+ - "POST handler races for completionTriggeredAt atomically — only winner sets status Signed"
+ - "Completion winner computes final PDF hash, sets signedAt, and fires notifications"
+ - "Agent notification email fires only at completion, not per-signer"
+ - "All signers receive completion emails with download links at completion"
+ - "Legacy single-signer documents still work end-to-end (null signerEmail path)"
+ artifacts:
+ - path: "teressa-copeland-homes/src/app/api/sign/[token]/route.ts"
+ provides: "Signer-aware GET + POST sign handlers with accumulate PDF and atomic completion"
+ exports: ["GET", "POST"]
+ key_links:
+ - from: "src/app/api/sign/[token]/route.ts GET"
+ to: "documents.signatureFields"
+ via: "filter by tokenRow.signerEmail"
+ pattern: "field\\.signerEmail.*tokenRow\\.signerEmail"
+ - from: "src/app/api/sign/[token]/route.ts POST"
+ to: "documents.completionTriggeredAt"
+ via: "UPDATE WHERE IS NULL RETURNING atomic guard"
+ pattern: "isNull.*completionTriggeredAt"
+ - from: "src/app/api/sign/[token]/route.ts POST"
+ to: "src/lib/signing/signing-mailer.tsx"
+ via: "sendSignerCompletionEmail + sendAgentNotificationEmail at completion"
+ pattern: "sendSignerCompletionEmail|sendAgentNotificationEmail"
+ - from: "src/app/api/sign/[token]/route.ts POST"
+ to: "src/lib/signing/token.ts"
+ via: "createSignerDownloadToken for download URLs"
+ pattern: "createSignerDownloadToken"
+---
+
+
+Rewrite the GET and POST sign handlers to be signer-aware. GET returns only this signer's fields. POST scopes date stamps and signature embedding to this signer, accumulates partial PDFs, and uses the completionTriggeredAt atomic guard to detect and process the final signer's submission.
+
+Purpose: This is the core multi-signer signing ceremony — without it, all signers see all fields and the first to sign "completes" the document.
+Output: Rewritten sign/[token]/route.ts with signer-aware field filtering, accumulate PDF, and atomic completion.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/15-multi-signer-backend/15-CONTEXT.md
+@.planning/phases/15-multi-signer-backend/15-RESEARCH.md
+@.planning/phases/15-multi-signer-backend/15-01-SUMMARY.md
+
+@teressa-copeland-homes/src/app/api/sign/[token]/route.ts
+@teressa-copeland-homes/src/lib/signing/embed-signature.ts
+@teressa-copeland-homes/src/lib/signing/token.ts
+@teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
+@teressa-copeland-homes/src/lib/db/schema.ts
+
+
+
+```typescript
+export interface SignatureToEmbed {
+ fieldId: string; dataURL: string; x: number; y: number; width: number; height: number; page: number;
+}
+export async function embedSignatureInPdf(
+ preparedPdfPath: string, signedPdfPath: string, signatures: SignatureToEmbed[]
+): Promise; // returns SHA-256 hex digest
+```
+
+
+```typescript
+export async function createSignerDownloadToken(documentId: string): Promise;
+```
+
+
+```typescript
+export async function sendSignerCompletionEmail(opts: { to: string; documentName: string; downloadUrl: string }): Promise;
+export async function sendAgentNotificationEmail(opts: { clientName: string; documentName: string; signedAt: Date }): Promise;
+```
+
+
+```typescript
+export function getFieldType(field: SignatureFieldData): SignatureFieldType;
+export function isClientVisibleField(field: SignatureFieldData): boolean;
+export function getSignerEmail(field: SignatureFieldData, fallbackEmail: string): string;
+export interface DocumentSigner { email: string; color: string; }
+// signingTokens table: jti, documentId, signerEmail (nullable), createdAt, expiresAt, usedAt
+// documents table: completionTriggeredAt (nullable timestamp), signers (nullable JSONB)
+```
+
+
+
+
+
+
+ Task 1: Rewrite GET sign handler with signer-aware field filtering
+ teressa-copeland-homes/src/app/api/sign/[token]/route.ts
+
+ - teressa-copeland-homes/src/app/api/sign/[token]/route.ts (full file — both GET and POST)
+ - teressa-copeland-homes/src/lib/db/schema.ts (isClientVisibleField, getFieldType, getSignerEmail, signingTokens.signerEmail)
+ - teressa-copeland-homes/node_modules/next/dist/docs/ (scan for route handler API)
+
+
+ Modify only the GET handler in `src/app/api/sign/[token]/route.ts`. The POST handler will be modified in Task 2.
+
+ **Changes to GET handler (D-04, D-05, D-06):**
+
+ After step 4 (fetch document), before step 5 (audit events), read `tokenRow.signerEmail` (already fetched in step 2) and apply field filtering:
+
+ Replace the current line 90:
+ ```typescript
+ signatureFields: (doc.signatureFields ?? []).filter(isClientVisibleField),
+ ```
+
+ With signer-aware filtering:
+ ```typescript
+ signatureFields: (doc.signatureFields ?? []).filter((field) => {
+ if (!isClientVisibleField(field)) return false;
+ // D-04: If token has signerEmail, only return this signer's fields
+ if (tokenRow.signerEmail !== null) {
+ return field.signerEmail === tokenRow.signerEmail;
+ }
+ // D-05: Legacy null-signer token — return all client-visible fields
+ return true;
+ }),
+ ```
+
+ **Also change step 5:** The `db.update(documents).set({ status: 'Viewed' })` on line 81 should NOT downgrade from 'Sent' to 'Viewed' if another signer has already started viewing. For multi-signer documents, only update to 'Viewed' if current status is 'Sent' (first viewer). This avoids a race where Signer B's GET overwrites 'Viewed' status from Signer A's GET. Add a where clause:
+
+ Actually, looking at the existing code, `status: 'Viewed'` is always an upgrade from 'Sent'. Multiple signers viewing is fine — they all set it to 'Viewed' which is idempotent. Keep this unchanged. The concern is only about downgrading from 'Signed', but the document can't be 'Signed' while tokens are unclaimed. Leave as-is.
+
+ **No other GET changes needed.** The rest of the handler (JWT verify, token lookup, one-time-use check, audit events) remains exactly the same.
+
+
+ cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - grep confirms signer-aware filter: `grep "tokenRow.signerEmail" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms field.signerEmail comparison: `grep "field.signerEmail === tokenRow.signerEmail" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms isClientVisibleField still used: `grep "isClientVisibleField" src/app/api/sign/\[token\]/route.ts`
+ - `npx tsc --noEmit` passes
+
+ GET handler returns only fields matching tokenRow.signerEmail for multi-signer tokens, and all client-visible fields for legacy null-signer tokens.
+
+
+
+ Task 2: Rewrite POST sign handler with signer-scoped operations, accumulate PDF, and atomic completion
+ teressa-copeland-homes/src/app/api/sign/[token]/route.ts
+
+ - teressa-copeland-homes/src/app/api/sign/[token]/route.ts (full file after Task 1)
+ - teressa-copeland-homes/src/lib/signing/embed-signature.ts (embedSignatureInPdf signature)
+ - teressa-copeland-homes/src/lib/signing/token.ts (createSignerDownloadToken)
+ - teressa-copeland-homes/src/lib/signing/signing-mailer.tsx (sendSignerCompletionEmail, sendAgentNotificationEmail)
+ - teressa-copeland-homes/src/lib/db/schema.ts (documents.completionTriggeredAt, signingTokens columns)
+
+
+ Rewrite the POST handler. The overall structure changes significantly but reuses all existing patterns. Add these imports at the top of the file (alongside existing imports):
+
+ ```typescript
+ import { sql, count } from 'drizzle-orm';
+ import { createSignerDownloadToken } from '@/lib/signing/token';
+ import { sendAgentNotificationEmail, sendSignerCompletionEmail } from '@/lib/signing/signing-mailer';
+ import { DocumentSigner } from '@/lib/db/schema';
+ ```
+
+ Note: `sendAgentNotificationEmail` import already exists — just add `sendSignerCompletionEmail` to the existing import. Add `sql` and `count` to the existing `drizzle-orm` import. Add `DocumentSigner` to the existing schema import.
+
+ **POST handler rewrite — step by step:**
+
+ Steps 1-3 (parse body, verify JWT, atomic token claim) remain EXACTLY as-is. Do not touch lines 100-135.
+
+ **Step 3.5 (NEW): Fetch tokenRow to get signerEmail:**
+ ```typescript
+ const tokenRow = await db.query.signingTokens.findFirst({
+ where: eq(signingTokens.jti, payload.jti),
+ });
+ const signerEmail = tokenRow?.signerEmail ?? null;
+ ```
+
+ Step 4 (audit log) — add `metadata: { signerEmail }` if signerEmail is not null:
+ ```typescript
+ await logAuditEvent({
+ documentId: payload.documentId,
+ eventType: 'signature_submitted',
+ ipAddress: ip,
+ userAgent: ua,
+ ...(signerEmail ? { metadata: { signerEmail } } : {}),
+ });
+ ```
+
+ Step 5 (fetch document) — keep exactly as-is.
+ Step 6 (guard) — keep exactly as-is.
+
+ **Step 7 (paths) — REPLACE with accumulate pattern (D-10):**
+ ```typescript
+ // Re-fetch 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 ?? doc.preparedFilePath!;
+ const workingAbsPath = path.join(UPLOADS_DIR, workingRelPath);
+
+ // Per-signer partial output path (unique by JTI — no collisions)
+ const partialRelPath = doc.preparedFilePath!.replace(/_prepared\.pdf$/, `_partial_${payload.jti}.pdf`);
+ const partialAbsPath = path.join(UPLOADS_DIR, partialRelPath);
+
+ // Path traversal guard (both paths)
+ if (!workingAbsPath.startsWith(UPLOADS_DIR) || !partialAbsPath.startsWith(UPLOADS_DIR)) {
+ return NextResponse.json({ error: 'forbidden' }, { status: 403 });
+ }
+ ```
+
+ **Step 8a (date stamping) — REPLACE with signer-scoped (D-09, Pitfall 3):**
+ ```typescript
+ const now = new Date();
+ const dateFields = (doc.signatureFields ?? []).filter((f) => {
+ if (getFieldType(f) !== 'date') return false;
+ if (!isClientVisibleField(f)) return false;
+ if (signerEmail !== null) return f.signerEmail === signerEmail;
+ return true; // legacy: stamp all date fields
+ });
+
+ let dateStampedPath = workingAbsPath;
+ if (dateFields.length > 0) {
+ const pdfBytes = await readFile(workingAbsPath);
+ const pdfDoc = await PDFDocument.load(pdfBytes);
+ const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
+ const pages = pdfDoc.getPages();
+ const signingDateStr = now.toLocaleDateString('en-US', {
+ month: '2-digit', day: '2-digit', year: 'numeric',
+ });
+ for (const field of dateFields) {
+ const page = pages[field.page - 1];
+ if (!page) continue;
+ page.drawText(signingDateStr, {
+ x: field.x + 4,
+ y: field.y + field.height / 2 - 4,
+ size: 10, font: helvetica, color: rgb(0.05, 0.05, 0.55),
+ });
+ }
+ const stampedBytes = await pdfDoc.save();
+ dateStampedPath = `${workingAbsPath}.datestamped_${payload.jti}.tmp`;
+ await writeFile(dateStampedPath, stampedBytes);
+ }
+ ```
+
+ Note: temp file name includes JTI to prevent collision between concurrent signers.
+
+ **Step 8b (signaturesWithCoords) — REPLACE with signer-scoped (Pitfall 4):**
+ ```typescript
+ 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
+ });
+
+ const signaturesWithCoords = signableFields.map((field) => {
+ const clientSig = signatures.find((s) => s.fieldId === field.id);
+ if (!clientSig) {
+ throw new Error(`Missing signature for field ${field.id}`);
+ }
+ return {
+ fieldId: field.id,
+ dataURL: clientSig.dataURL,
+ x: field.x,
+ y: field.y,
+ width: field.width,
+ height: field.height,
+ page: field.page,
+ };
+ });
+ ```
+
+ **Step 9 (embed) — REPLACE input/output paths:**
+ ```typescript
+ let pdfHash: string;
+ try {
+ pdfHash = await embedSignatureInPdf(dateStampedPath, partialAbsPath, signaturesWithCoords);
+ } catch (err) {
+ console.error('[sign/POST] embedSignatureInPdf failed:', err);
+ return NextResponse.json({ error: 'pdf-embed-failed' }, { status: 500 });
+ }
+ ```
+
+ **Step 9.5 (cleanup date-stamp temp):**
+ ```typescript
+ if (dateStampedPath !== workingAbsPath) {
+ unlink(dateStampedPath).catch(() => {});
+ }
+ ```
+
+ **Step 10 (audit log) — keep as-is but add metadata:**
+ ```typescript
+ await logAuditEvent({
+ documentId: payload.documentId,
+ eventType: 'pdf_hash_computed',
+ ipAddress: ip,
+ userAgent: ua,
+ metadata: { hash: pdfHash, signedFilePath: partialRelPath, ...(signerEmail ? { signerEmail } : {}) },
+ });
+ ```
+
+ **Step 10.5 (NEW: update signedFilePath to this signer's partial):**
+ ```typescript
+ await db.update(documents)
+ .set({ signedFilePath: partialRelPath })
+ .where(eq(documents.id, payload.documentId));
+ ```
+
+ **Step 11 (NEW: completion detection — D-07, D-08):**
+ ```typescript
+ // Count remaining unclaimed tokens
+ const remaining = await db
+ .select({ cnt: sql`cast(count(*) as int)` })
+ .from(signingTokens)
+ .where(and(
+ eq(signingTokens.documentId, payload.documentId),
+ isNull(signingTokens.usedAt)
+ ));
+ const allDone = remaining[0].cnt === 0;
+
+ if (!allDone) {
+ // Not all signers done — return success for this signer
+ return NextResponse.json({ ok: true });
+ }
+
+ // All tokens claimed — race for completionTriggeredAt
+ 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 the race — still success for this signer
+ return NextResponse.json({ ok: true });
+ }
+ ```
+
+ **Step 12 (REPLACE: only completion winner updates status):**
+ ```typescript
+ // Winner: finalize document
+ await db
+ .update(documents)
+ .set({
+ status: 'Signed',
+ signedAt: now,
+ signedFilePath: partialRelPath, // already set above, but confirm final value
+ pdfHash,
+ })
+ .where(eq(documents.id, payload.documentId));
+ ```
+
+ **Step 13 (REPLACE: fire-and-forget notifications — D-12):**
+ ```typescript
+ // Fire-and-forget: agent notification + signer completion emails
+ const baseUrl = process.env.APP_BASE_URL ?? 'http://localhost:3000';
+
+ // Agent notification
+ db.query.documents
+ .findFirst({
+ where: eq(documents.id, payload.documentId),
+ with: { client: { columns: { name: true } } },
+ columns: { name: true, signers: true },
+ })
+ .then(async (notifDoc) => {
+ try {
+ const signers = (notifDoc?.signers as DocumentSigner[] | null) ?? [];
+ const clientName = signers.length > 0
+ ? `${signers.length} signers`
+ : ((notifDoc as { client?: { name: string } } & typeof notifDoc)?.client?.name ?? 'Client');
+ await sendAgentNotificationEmail({
+ clientName,
+ documentName: notifDoc?.name ?? doc.name,
+ signedAt: now,
+ });
+ } catch (emailErr) {
+ console.error('[sign/POST] agent notification email failed (non-fatal):', emailErr);
+ }
+ })
+ .catch((err) => {
+ console.error('[sign/POST] agent notification fetch failed (non-fatal):', err);
+ });
+
+ // Signer completion emails (MSIGN-11)
+ db.query.documents
+ .findFirst({
+ where: eq(documents.id, payload.documentId),
+ columns: { name: true, signers: true },
+ })
+ .then(async (completionDoc) => {
+ try {
+ const signers = (completionDoc?.signers as DocumentSigner[] | null) ?? [];
+ if (signers.length === 0) return; // legacy single-signer — no signer completion emails
+
+ const downloadToken = await createSignerDownloadToken(payload.documentId);
+ const downloadUrl = `${baseUrl}/api/sign/download/${downloadToken}`;
+
+ await Promise.all(
+ signers.map((signer) =>
+ sendSignerCompletionEmail({
+ to: signer.email,
+ documentName: completionDoc?.name ?? doc.name,
+ downloadUrl,
+ }).catch((err) => {
+ console.error(`[sign/POST] signer completion email to ${signer.email} failed (non-fatal):`, err);
+ })
+ )
+ );
+ } catch (err) {
+ console.error('[sign/POST] signer completion emails failed (non-fatal):', err);
+ }
+ })
+ .catch((err) => {
+ console.error('[sign/POST] signer completion fetch failed (non-fatal):', err);
+ });
+ ```
+
+ **Step 14 (return):** Keep `return NextResponse.json({ ok: true });`
+
+ **IMPORTANT NOTES for executor:**
+ - The `sql` import is from `drizzle-orm` — add it to the existing import line
+ - The `count` import is NOT needed — we use `sql` directly
+ - `DocumentSigner` import is from `@/lib/db/schema` — add to existing import
+ - `createSignerDownloadToken` import is from `@/lib/signing/token` — add to existing import
+ - `sendSignerCompletionEmail` import is from `@/lib/signing/signing-mailer` — add alongside existing `sendAgentNotificationEmail`
+ - The `isNull` import already exists from drizzle-orm — confirm it's in the import line
+ - Keep ALL existing imports that are still used
+
+
+ cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - grep confirms signer-scoped field filter in GET: `grep -c "field.signerEmail === tokenRow.signerEmail" src/app/api/sign/\[token\]/route.ts` returns at least 3 (GET filter, date filter, signable filter)
+ - grep confirms completionTriggeredAt guard: `grep "completionTriggeredAt" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms isNull(documents.completionTriggeredAt): `grep "isNull(documents.completionTriggeredAt)" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms partialRelPath JTI pattern: `grep "_partial_" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms remaining token count: `grep "usedAt" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms sendSignerCompletionEmail call: `grep "sendSignerCompletionEmail" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms createSignerDownloadToken call: `grep "createSignerDownloadToken" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms sendAgentNotificationEmail still called: `grep "sendAgentNotificationEmail" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms APP_BASE_URL in POST: `grep "APP_BASE_URL" src/app/api/sign/\[token\]/route.ts`
+ - grep confirms status Signed only at completion: `grep "status: 'Signed'" src/app/api/sign/\[token\]/route.ts | wc -l` returns 1 (only in the won block)
+ - `npx tsc --noEmit` passes with zero errors
+
+
+ GET handler returns signer-scoped fields for multi-signer tokens and all client-visible fields for legacy tokens.
+ POST handler: date stamps scoped to this signer's date fields, signatures scoped to this signer's signable fields, PDF accumulated via JTI-keyed partial paths, signedFilePath updated after each signer, remaining-token count check triggers completion detection, completionTriggeredAt atomic guard ensures only one winner sets status to Signed. Winner sends agent notification + signer completion emails with download links. Legacy single-signer documents work unchanged (null signerEmail path).
+
+
+
+
+
+
+`cd teressa-copeland-homes && npx tsc --noEmit` passes
+GET handler field filtering uses tokenRow.signerEmail
+POST handler has 5 signer-scoped operations: date fields, signable fields, working path, partial output path, temp file name
+Completion guard uses UPDATE WHERE IS NULL RETURNING pattern on completionTriggeredAt
+Agent notification fires only in the completion winner block
+Signer completion emails fire only in the completion winner block
+Legacy null-signer path falls through all filters unchanged
+
+
+
+1. Multi-signer GET: token with signerEmail='alice@x.com' returns only alice's fields (fields with signerEmail='bob@x.com' are excluded)
+2. Legacy GET: token with null signerEmail returns all isClientVisibleField fields (unchanged behavior)
+3. Multi-signer POST: signer A's submission embeds only A's signatures and date stamps, writes to `_partial_{jtiA}.pdf`, updates signedFilePath
+4. Multi-signer POST: when signer B (last signer) submits, remaining count = 0, completionTriggeredAt is claimed, status set to Signed, agent + signer emails fired
+5. Concurrent last-signers: only one wins the completionTriggeredAt race; the other returns `{ ok: true }` without triggering notifications
+6. Legacy POST: null signerEmail stamps all date fields, embeds all client-visible signable fields, unconditionally sets Signed (remaining count = 0 immediately since there's only 1 token)
+7. `npx tsc --noEmit` passes
+
+
+