Files

23 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
15-multi-signer-backend 03 execute 2
15-01
teressa-copeland-homes/src/app/api/sign/[token]/route.ts
true
MSIGN-07
MSIGN-10
MSIGN-11
truths artifacts key_links
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)
path provides exports
teressa-copeland-homes/src/app/api/sign/[token]/route.ts Signer-aware GET + POST sign handlers with accumulate PDF and atomic completion
GET
POST
from to via pattern
src/app/api/sign/[token]/route.ts GET documents.signatureFields filter by tokenRow.signerEmail field.signerEmail.*tokenRow.signerEmail
from to via pattern
src/app/api/sign/[token]/route.ts POST documents.completionTriggeredAt UPDATE WHERE IS NULL RETURNING atomic guard isNull.*completionTriggeredAt
from to via pattern
src/app/api/sign/[token]/route.ts POST src/lib/signing/signing-mailer.tsx sendSignerCompletionEmail + sendAgentNotificationEmail at completion sendSignerCompletionEmail|sendAgentNotificationEmail
from to via pattern
src/app/api/sign/[token]/route.ts POST src/lib/signing/token.ts createSignerDownloadToken for download URLs 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

```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 ```
export async function createSignerDownloadToken(documentId: string): Promise<string>;
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>;
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<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
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

<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>
After completion, create `.planning/phases/15-multi-signer-backend/15-03-SUMMARY.md`