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

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

<interfaces>
<!-- Key types and contracts the executor needs. -->

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<string>;
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<void>;
export async function sendAgentNotificationEmail(opts: { clientName: string; documentName: string; signedAt: Date }): Promise<void>;
```

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
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Extend token.ts with signerEmail param and signer-download token pair</name>
  <files>teressa-copeland-homes/src/lib/signing/token.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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<string> {
      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.
  </action>
  <verify>
    <automated>cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>createSigningToken accepts optional signerEmail and persists it. createSignerDownloadToken/verifySignerDownloadToken produce and validate 72h JWTs with purpose signer-download. All existing call sites compile unchanged.</done>
</task>

<task type="auto">
  <name>Task 2: Add sendSignerCompletionEmail to signing-mailer.tsx</name>
  <files>teressa-copeland-homes/src/lib/signing/signing-mailer.tsx</files>
  <read_first>
    - teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
  </read_first>
  <action>
    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<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 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`.
  </action>
  <verify>
    <automated>cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>sendSignerCompletionEmail is exported from signing-mailer.tsx and sends a plain-text email with the signed document download link to a signer.</done>
</task>

<task type="auto">
  <name>Task 3: Create public signer download route GET /api/sign/download/[token]</name>
  <files>teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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"`,
        },
      });
    }
    ```
  </action>
  <verify>
    <automated>cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>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.</done>
</task>

</tasks>

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

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

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