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

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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

<interfaces>
<!-- From embed-signature.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<string>; // returns SHA-256 hex digest
```

<!-- From token.ts (after Plan 01): -->
```typescript
export async function createSignerDownloadToken(documentId: string): Promise<string>;
```

<!-- From signing-mailer.tsx (after Plan 01): -->
```typescript
export async function sendSignerCompletionEmail(opts: { to: string; documentName: string; downloadUrl: string }): Promise<void>;
export async function sendAgentNotificationEmail(opts: { clientName: string; documentName: string; signedAt: Date }): Promise<void>;
```

<!-- From schema.ts: -->
```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)
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Rewrite GET sign handler with signer-aware field filtering</name>
  <files>teressa-copeland-homes/src/app/api/sign/[token]/route.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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.
  </action>
  <verify>
    <automated>cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>GET handler returns only fields matching tokenRow.signerEmail for multi-signer tokens, and all client-visible fields for legacy null-signer tokens.</done>
</task>

<task type="auto">
  <name>Task 2: Rewrite POST sign handler with signer-scoped operations, accumulate PDF, and atomic completion</name>
  <files>teressa-copeland-homes/src/app/api/sign/[token]/route.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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<number>`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<number>` 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
  </action>
  <verify>
    <automated>cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>
    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).
  </done>
</task>

</tasks>

<verification>
`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
</verification>

<success_criteria>
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
</success_criteria>

<output>
After completion, create `.planning/phases/15-multi-signer-backend/15-03-SUMMARY.md`
</output>
