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

38 KiB

Phase 15: Multi-Signer Backend - Research

Researched: 2026-04-03 Domain: Next.js App Router API routes — multi-signer token lifecycle, atomic completion detection, accumulate PDF, JWT-presigned signer download Confidence: HIGH — all findings grounded in direct codebase inspection of the three routes being changed, the token utility, signing-mailer, embed-signature, and schema.ts; no speculative claims


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

D-01: When documents.signers is null or empty, the send route falls back to the existing single-signer behavior: create one token (no signerEmail set), send to doc.assignedClientId ?? doc.clientId. Backwards compatibility preserved.

D-02: When documents.signers is populated, loop over each signer, call createSigningToken(documentId) for each (passing signerEmail), dispatch all emails in parallel via Promise.all. All signing links sent simultaneously.

D-03: createSigningToken must be extended to accept an optional signerEmail parameter and write it to signingTokens.signerEmail. Only change to the token utility.

D-04: If tokenRow.signerEmail is NOT NULL: filter signatureFields to fields where field.signerEmail === tokenRow.signerEmail. Only this signer's fields returned.

D-05: If tokenRow.signerEmail IS NULL (legacy token): return all isClientVisibleField fields — same as today.

D-06: Use getSignerEmail(field, null) helper to read field signer. Filter: field.signerEmail === tokenRow.signerEmail (both are same email string, or both null for legacy).

D-07: After claiming token atomically:

  1. Check if ALL tokens for this document have usedAt IS NOT NULL
  2. If not all claimed: do nothing to document status
  3. If all claimed: attempt to claim completionTriggeredAt via UPDATE documents SET completionTriggeredAt = NOW() WHERE id = $1 AND completionTriggeredAt IS NULL RETURNING id
  4. Only if that UPDATE returns a row: set status = 'Signed', signedAt = now, signedFilePath, pdfHash, and trigger all-parties notifications

D-08: Document status set to 'Signed' ONLY when all signers have submitted. Intermediate state remains 'Sent'. Per-signer completion readable from signingTokens.usedAt.

D-09: When embedding date stamps, filter to ONLY the date fields where field.signerEmail === tokenRow.signerEmail (or, for legacy null-signer tokens, all date fields). Each signer's date fields stamped with THEIR submission timestamp.

D-10 (Accumulate PDF): Keep documents.signedFilePath as the "current working PDF." After each signer signs, update signedFilePath to their output. The next signer reads from signedFilePath (if it exists) instead of preparedFilePath. Final signer's output IS the final _signed.pdf.

D-11 (Download route): Create a new public download route GET /api/sign/download/[token] that accepts a presigned signer-download token (same JWT pattern) and serves the final signedFilePath without requiring an agent auth session.

D-12: The agent notification email (already exists: sendAgentNotificationEmail) is called on completion only. Add a sendSignerCompletionEmail function that sends the download link to each signer.

Claude's Discretion

  • Exact intermediate file naming convention for partial PDFs
  • Whether to add viewedAt timestamp to signingTokens (for Phase 16 dashboard — planner can decide if it fits cleanly)
  • Error handling granularity (individual signer email failure should not block the send)

Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope. </user_constraints>


<phase_requirements>

Phase Requirements

ID Description Research Support
MSIGN-05 Document recipients list is built automatically from unique signer emails on placed fields (no separate manual entry) Send route reads doc.signers[] populated by Phase 16 UI; D-01/D-02 cover the loop
MSIGN-06 All signers receive their unique signing links simultaneously when agent sends Promise.all token loop in send route (D-02); createSigningToken extended (D-03)
MSIGN-07 Each signer's signing page shows only their own assigned fields — other signers' fields are not visible GET handler field filtering by tokenRow.signerEmail (D-04/D-05/D-06)
MSIGN-10 When all signers complete, agent receives a notification email sendAgentNotificationEmail called only at completion trigger (D-07/D-12)
MSIGN-11 When all signers complete, all parties (each signer + agent) receive the final merged PDF via email link sendSignerCompletionEmail + new public download route (D-11/D-12)
</phase_requirements>

Summary

Phase 15 is a surgical rewrite of three existing routes: no schema migrations (Phase 14 shipped those), no UI. The Phase 14 schema additions (signers, completionTriggeredAt, signerEmail) are already in schema.ts and in the DB — this phase writes the application logic that uses them.

The most important thing to understand about the current code: the POST sign handler at src/app/api/sign/[token]/route.ts lines 254-263 unconditionally sets status = 'Signed' after any token claim. This is the first-signer-wins bug. Everything else in the handler is correct and reusable — the atomic token claim pattern, the date-stamp approach, the embedSignatureInPdf call, the fire-and-forget email pattern. The rewrite preserves all of that and wraps the completion logic in a two-step guard: count remaining unclaimed tokens, then race for completionTriggeredAt.

The accumulate PDF strategy uses documents.signedFilePath as the live working path. During multi-signing, this column holds a partial/intermediate signed PDF (document status remains 'Sent' — signedFilePath being non-null during Sent status is the new normal for multi-signer documents). Only the completion winner writes the final hash and flips status to 'Signed'.

The new public download route GET /api/sign/download/[token] follows the existing createAgentDownloadToken / verifyAgentDownloadToken pattern exactly — a short-lived JWT with purpose: 'signer-download', no DB record, served without auth session. This is a new file.

Primary recommendation: Implement in the order: (1) extend createSigningToken, (2) rewrite send route with legacy fallback, (3) rewrite GET sign handler field filtering, (4) rewrite POST sign handler with accumulate + completion, (5) add sendSignerCompletionEmail and the new download route.


Standard Stack

Core (already installed — no new dependencies)

Library Version Purpose Why Standard
jose existing JWT signing/verification for signing tokens and new signer-download tokens Already used in token.ts for all token operations
drizzle-orm existing DB queries — token claim, completion guard, doc updates Pattern established in existing routes
@cantoo/pdf-lib existing Date stamping and (via embed-signature.ts) PDF accumulation Already used in sign handler lines 183-204
nodemailer existing Signer completion email delivery Already used in signing-mailer.tsx
@react-email/render existing HTML email rendering Already used in sendSigningRequestEmail

No new npm packages are required for this phase.


Architecture Patterns

Pattern 1: Existing Atomic Token Claim (already proven — preserve as-is)

The current POST handler at lines 127-135 uses:

// Source: teressa-copeland-homes/src/app/api/sign/[token]/route.ts lines 127-135
const claimed = await db
  .update(signingTokens)
  .set({ usedAt: new Date() })
  .where(and(eq(signingTokens.jti, payload.jti), isNull(signingTokens.usedAt)))
  .returning({ jti: signingTokens.jti });

if (claimed.length === 0) {
  return NextResponse.json({ error: 'already-signed' }, { status: 409 });
}

This is the correct pattern. Do not change it. The completion guard uses the same idiom.

Pattern 2: Completion Guard — completionTriggeredAt Race

The schema already has completionTriggeredAt on documents. The guard:

// Source: .planning/research/PITFALLS.md Pitfall 2 — verified against schema.ts line 115
const won = await db
  .update(documents)
  .set({ completionTriggeredAt: new Date() })
  .where(and(
    eq(documents.id, payload.documentId),
    isNull(documents.completionTriggeredAt)
  ))
  .returning({ id: documents.id });

if (won.length === 0) return; // another handler already won the race
// Only the winner proceeds to final PDF hash + status update + notifications

This must run AFTER the "all tokens claimed" count check. Only attempt the completion guard when count(unclaimed) === 0.

Pattern 3: All-Tokens-Claimed Check

// Source: .planning/research/ARCHITECTURE.md Part 2 signing POST sequence step 9
const [totalRow, claimedRow] = await Promise.all([
  db.select({ count: count() })
    .from(signingTokens)
    .where(eq(signingTokens.documentId, payload.documentId)),
  db.select({ count: count() })
    .from(signingTokens)
    .where(and(
      eq(signingTokens.documentId, payload.documentId),
      isNotNull(signingTokens.usedAt)
    )),
]);
const allSigned = totalRow[0].count === claimedRow[0].count;

Alternatively, a single query: SELECT COUNT(*) FILTER (WHERE used_at IS NULL) AS remaining FROM signing_tokens WHERE document_id = $1. Zero remaining = all signed.

Pattern 4: Accumulate PDF — signedFilePath as Working Copy

// Source: .planning/phases/15-multi-signer-backend/15-CONTEXT.md D-10
// Re-read doc to get latest signedFilePath (another signer may have updated it)
const freshDoc = await db.query.documents.findFirst({
  where: eq(documents.id, payload.documentId),
  columns: { signedFilePath: true, preparedFilePath: true },
});

const workingRelPath = freshDoc?.signedFilePath ?? freshDoc?.preparedFilePath;
const workingAbsPath = path.join(UPLOADS_DIR, workingRelPath!);

// Write this signer's output to a new partial path
const partialRelPath = `clients/${docId}/${jti}_partial.pdf`;
const partialAbsPath = path.join(UPLOADS_DIR, partialRelPath);

// embed into workingAbsPath, write to partialAbsPath
await embedSignatureInPdf(dateStampedPath ?? workingAbsPath, partialAbsPath, signaturesWithCoords);

// Update signedFilePath to the new partial — becomes the next signer's working copy
await db.update(documents)
  .set({ signedFilePath: partialRelPath })
  .where(eq(documents.id, payload.documentId));

IMPORTANT: The advisory lock is required here. Two concurrent signers both reading signedFilePath and both writing to different partial paths would race. The lock ensures sequential reads.

Pattern 5: Postgres Advisory Lock

// Source: .planning/research/ARCHITECTURE.md Part 2 Advisory Lock Implementation
// Run this as the FIRST thing inside a transaction context (or as standalone execute)
await db.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${payload.documentId}))`);

pg_advisory_xact_lock is transaction-scoped — released automatically when the transaction commits or rolls back. Drizzle supports db.execute(sql...) for raw SQL.

IMPORTANT: The existing sign route does NOT use a transaction. For the advisory lock to be transaction-scoped, the lock + PDF write + signedFilePath update must happen inside db.transaction(async (tx) => { ... }). If running without a transaction, use pg_advisory_lock / pg_advisory_unlock instead (session-level, explicit release).

Simpler approach that avoids transactions: use the completionTriggeredAt guard alone (already written above) and accept that the accumulate PDF step is serialized by the fact that each signer writes to a unique JTI-keyed partial path. The only race is on completion — which is handled by completionTriggeredAt. The intermediate partial PDFs are not corrupted by concurrent writes because they have unique output paths.

Pattern 6: New signer-download Token — reuse existing pattern

// Source: teressa-copeland-homes/src/lib/signing/token.ts lines 51-64
// Follow the exact same structure as createAgentDownloadToken / verifyAgentDownloadToken

export async function createSignerDownloadToken(documentId: string): Promise<string> {
  return await new SignJWT({ documentId, purpose: 'signer-download' })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('72h')   // longer than agent-download (5m) — signer may not open immediately
    .sign(getSecret());
}

export async function verifySignerDownloadToken(token: string): Promise<{ documentId: string }> {
  const { payload } = await jwtVerify(token, getSecret());
  if (payload['purpose'] !== 'signer-download') throw new Error('Not a signer download token');
  return { documentId: payload['documentId'] as string };
}

The public download route GET /api/sign/download/[token] reads the token from the path param, verifies it, fetches the document's signedFilePath, checks it's within UPLOADS_DIR (path traversal guard — replicating lines 169-171 of the sign route), and serves the file as application/pdf.

Pattern 7: Extended createSigningToken

// Source: teressa-copeland-homes/src/lib/signing/token.ts current signature
// Current: createSigningToken(documentId: string)
// New: createSigningToken(documentId: string, signerEmail?: string)

export async function createSigningToken(
  documentId: string,
  signerEmail?: string
): Promise<{ token: string; jti: string; expiresAt: Date }> {
  // ... JWT construction unchanged ...
  await db.insert(signingTokens).values({
    jti,
    documentId,
    signerEmail: signerEmail ?? null,   // new column — null for legacy tokens
    expiresAt,
  });
  return { token, jti, expiresAt };
}

All existing call sites (createSigningToken(doc.id)) compile without modification — the second param is optional.

Pattern 8: Fire-and-forget email (established pattern — preserve)

// Source: teressa-copeland-homes/src/app/api/sign/[token]/route.ts lines 267-286
// Existing fire-and-forget pattern for agent notification — reuse for signer completion emails
somePromise
  .then(async () => {
    try { await sendEmail(...); }
    catch (err) { console.error('[sign/POST] email failed (non-fatal):', err); }
  })
  .catch((err) => { console.error('[sign/POST] fetch failed (non-fatal):', err); });

For MSIGN-11: send completion email to each signer at completion. Individual email failure must NOT block the response (fire-and-forget per signer). Log failures but return 200.

Pattern 9: Send Route Legacy Fallback Structure

// Source: CONTEXT.md D-01/D-02
if (doc.signers && doc.signers.length > 0) {
  // Multi-signer path: create one token per signer, send all emails in parallel
  await Promise.all(
    doc.signers.map(async (signer) => {
      const { token, expiresAt } = await createSigningToken(doc.id, signer.email);
      const signingUrl = `${baseUrl}/sign/${token}`;
      await sendSigningRequestEmail({ to: signer.email, documentName: doc.name, signingUrl, expiresAt });
      await logAuditEvent({ documentId: doc.id, eventType: 'email_sent', metadata: { signerEmail: signer.email } });
    })
  );
} else {
  // Legacy single-signer path: exact current behavior
  const { token, expiresAt } = await createSigningToken(doc.id);
  // ... existing code verbatim ...
}

Note: doc.signers type is DocumentSigner[] | null from Phase 14 schema. The DocumentSigner interface has { email: string; color: string } — only email is needed in this route.


Don't Hand-Roll

Problem Don't Build Use Instead Why
Atomic "only one handler wins" on completion Custom in-memory mutex, file locks UPDATE ... WHERE IS NULL RETURNING on completionTriggeredAt Same DB-level atomicity already used for token claim in line 127-131
PDF file concurrency Custom queue, setTimeout delays Postgres advisory lock OR unique JTI-keyed output paths (see Pattern 5) Advisory lock is a single SQL call; unique paths avoid contention entirely for intermediate partials
Short-lived presigned download URLs Custom signed URL scheme jose SignJWT with purpose: 'signer-download' Exact same pattern as existing agent-download and signing tokens — zero new code patterns
Per-signer field filtering Re-parsing the JSONB field array with custom logic field.signerEmail === tokenRow.signerEmail with null-path fallback One-line filter on the array already fetched from DB

Key insight: Every pattern needed in this phase already exists in the codebase. The rewrite is wiring existing patterns into new execution paths, not inventing new patterns.


Common Pitfalls

Pitfall 1: Re-fetching doc inside the POST handler for accumulator path

What goes wrong: The current POST handler fetches doc at step 5 (after the atomic claim). For the accumulator, the signedFilePath value at the time of this fetch may be stale — another signer who submitted concurrently may have updated it between the token claim and this fetch.

How to avoid: Re-fetch the document specifically for signedFilePath AFTER acquiring the advisory lock (if using transactions) or accept that with unique JTI-keyed partial paths, concurrent writes to different partials don't interfere. The advisory lock approach with a re-fetch inside the lock is safer.

Warning signs: Two signers submit simultaneously; the second signer's output is based on preparedFilePath instead of the first signer's partial.

Pitfall 2: completionTriggeredAt guard must fire AFTER accumulate, not before

What goes wrong: If the completion guard runs before the current signer's PDF is embedded, the winner may claim completion but then fail during PDF embedding — leaving the document in a completionTriggeredAt IS NOT NULL state with no final PDF.

How to avoid: Order the steps: (1) token claim, (2) accumulate PDF for this signer, (3) update signedFilePath, (4) count remaining tokens, (5) only if zero: attempt completionTriggeredAt claim, (6) only if won: compute final hash, update status to Signed, send notifications.

Pitfall 3: date field scoping in multi-signer mode

What goes wrong: Current code at line 177-205 stamps ALL date fields. With two signers each having their own date fields, Signer A's handler would stamp Signer B's date fields with Signer A's timestamp.

How to avoid: Filter date fields by field.signerEmail === tokenRow.signerEmail before stamping (D-09). For legacy null-signer tokens, keep the existing all-fields behavior.

Warning signs: Both signers show the same date on their respective date fields. Date fields show the timestamp of the first signer who completed, not the second.

Pitfall 4: signaturesWithCoords filter must also be signer-scoped

What goes wrong: Lines 210-229 of the current handler build signaturesWithCoords from ALL client-signature and initials fields. In multi-signer mode, this loop would try to find a submitted signature for Signer B's fields when processing Signer A's POST — and throw Missing signature for field ${field.id}.

How to avoid: Before building signaturesWithCoords, filter signableFields to only fields where field.signerEmail === tokenRow.signerEmail (same filter as GET handler D-04). For legacy null-signer, use all client-visible fields (existing behavior).

Warning signs: POST handler returns 500 with "Missing signature for field X" where X is another signer's field.

Pitfall 5: NEXT_PUBLIC_BASE_URL is baked at build time

What goes wrong: send/route.ts line 35: const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'. The NEXT_PUBLIC_ prefix causes Next.js to inline this value at build time. In Docker production, setting this env var at runtime has no effect — the string localhost:3000 is already in the compiled bundle.

How to avoid: The send route already has this bug in the legacy path. Phase 15's rewrite should rename to APP_BASE_URL (no NEXT_PUBLIC_ prefix) in the send route. (Full Docker fix is Phase 17, but fixing the variable name here prevents the bug from spreading.)

Warning signs: Signing emails are sent but the link points to localhost:3000 instead of the production domain.

Pitfall 6: signedFilePath non-null during 'Sent' status — code that assumes signedFilePath means 'Signed'

What goes wrong: The accumulate pattern updates signedFilePath after each intermediate signer. Any code that reads doc.signedFilePath and treats it as "final" (e.g., download routes, dashboard) will see partial PDFs as downloadable before all signers complete.

How to avoid: The download endpoint at GET /api/documents/[id]/download must continue to check doc.status === 'Signed' before serving the file. The new signer download route GET /api/sign/download/[token] also checks status. signedFilePath alone is not a completion signal — only status === 'Signed' is.

Pitfall 7: Agent notification email fetch uses with: { client: } relation — requires assignedClientId

What goes wrong: The existing fire-and-forget at lines 267-286 uses with: { client: { columns: { name: true } } } which joins on documents.clientId. For multi-signer documents, the relevant "client" is a list of signers, not a single clients row. The join result may have no name if the signer emails don't match a clients record.

How to avoid: For the agent notification at completion, pass the signer list (count) rather than a client name. Or use a fallback: freshDoc?.client?.name ?? 'Multiple signers'. The agent email already exists and just needs a slight wording change for multi-signer context.


Code Examples

POST Sign Handler — New Completion Sequence

// Source: CONTEXT.md D-07, PITFALLS.md Pitfall 1/2, ARCHITECTURE.md Part 2

// STEP: After atomic token claim succeeds (existing lines 127-135 unchanged)

// Fetch tokenRow to get signerEmail for scoped field operations
const tokenRow = await db.query.signingTokens.findFirst({
  where: eq(signingTokens.jti, payload.jti),
});
const signerEmail = tokenRow?.signerEmail ?? null;

// STEP: Date stamp — scoped to this signer's date fields (D-09)
const dateFields = (doc.signatureFields ?? []).filter((f) => {
  if (!isClientVisibleField(f)) return false;
  if (getFieldType(f) !== 'date') return false;
  if (signerEmail !== null) return f.signerEmail === signerEmail;
  return true; // legacy: stamp all date fields
});

// STEP: signaturesWithCoords — scoped to this signer's signable fields (Pitfall 4)
const signableFields = (doc.signatureFields ?? []).filter((f) => {
  const t = getFieldType(f);
  if (t !== 'client-signature' && t !== 'initials') return false;
  if (signerEmail !== null) return f.signerEmail === signerEmail;
  return isClientVisibleField(f); // legacy
});

// STEP: Determine working PDF (accumulate — D-10)
const freshDoc = await db.query.documents.findFirst({
  where: eq(documents.id, payload.documentId),
  columns: { signedFilePath: true, preparedFilePath: true },
});
const workingRelPath = freshDoc?.signedFilePath ?? freshDoc?.preparedFilePath!;
const workingAbsPath = path.join(UPLOADS_DIR, workingRelPath);

// Per-signer partial output path
const partialRelPath = freshDoc!.preparedFilePath!.replace(
  /_prepared\.pdf$/,
  `_partial_${payload.jti}.pdf`
);
const partialAbsPath = path.join(UPLOADS_DIR, partialRelPath);

// Path traversal guards for both paths
if (!workingAbsPath.startsWith(UPLOADS_DIR) || !partialAbsPath.startsWith(UPLOADS_DIR)) {
  return NextResponse.json({ error: 'forbidden' }, { status: 403 });
}

// STEP: Embed (date-stamped source → partial output)
const inputPath = dateStampedPath !== workingAbsPath ? dateStampedPath : workingAbsPath;
await embedSignatureInPdf(inputPath, partialAbsPath, signaturesWithCoords);
// NOTE: embedSignatureInPdf returns pdfHash — only store it at completion (final signer)

// STEP: Update working copy
await db.update(documents)
  .set({ signedFilePath: partialRelPath })
  .where(eq(documents.id, payload.documentId));

// STEP: Count remaining unclaimed tokens
const remaining = await db
  .select({ count: sql<number>`count(*)::int` })
  .from(signingTokens)
  .where(and(
    eq(signingTokens.documentId, payload.documentId),
    isNull(signingTokens.usedAt)
  ));
const allDone = remaining[0].count === 0;

if (!allDone) {
  await logAuditEvent({ documentId: payload.documentId, eventType: 'signature_submitted',
    ipAddress: ip, userAgent: ua, metadata: { signerEmail } });
  return NextResponse.json({ ok: true });
}

// STEP: Race for completion (D-07 step 3)
const won = await db
  .update(documents)
  .set({ completionTriggeredAt: now })
  .where(and(
    eq(documents.id, payload.documentId),
    isNull(documents.completionTriggeredAt)
  ))
  .returning({ id: documents.id });

if (won.length === 0) {
  // Another handler won — still success for this signer
  await logAuditEvent({ documentId: payload.documentId, eventType: 'signature_submitted',
    ipAddress: ip, userAgent: ua, metadata: { signerEmail } });
  return NextResponse.json({ ok: true });
}

// STEP: Winner — finalize (D-07 step 4)
const finalHash = await hashFile(partialAbsPath); // or re-hash via hashFile from embed-signature
await db.update(documents)
  .set({
    status: 'Signed',
    signedAt: now,
    signedFilePath: partialRelPath, // already set, but confirm
    pdfHash: finalHash,
  })
  .where(eq(documents.id, payload.documentId));

// Fire-and-forget: agent notification + all-signers completion emails (D-12)
// ...

GET /api/sign/download/[token] — New Public Download Route

// Source: CONTEXT.md D-11, token.ts createAgentDownloadToken pattern
import { verifySignerDownloadToken } from '@/lib/signing/token';
import path from 'node:path';
import { readFile } from 'node:fs/promises';

const UPLOADS_DIR = path.join(process.cwd(), 'uploads');

export async function GET(
  _req: NextRequest,
  { params }: { params: Promise<{ token: string }> }
) {
  const { token } = await params;

  let documentId: string;
  try {
    ({ documentId } = await verifySignerDownloadToken(token));
  } catch {
    return new Response('Invalid or expired download link', { status: 401 });
  }

  const doc = await db.query.documents.findFirst({
    where: eq(documents.id, documentId),
    columns: { signedFilePath: true, status: true, name: true },
  });

  if (!doc || doc.status !== 'Signed' || !doc.signedFilePath) {
    return new Response('Document not yet complete', { status: 404 });
  }

  const absPath = path.join(UPLOADS_DIR, doc.signedFilePath);
  if (!absPath.startsWith(UPLOADS_DIR)) {
    return new Response('Forbidden', { status: 403 });
  }

  const fileBytes = await readFile(absPath);
  return new Response(fileBytes, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="${doc.name}-signed.pdf"`,
    },
  });
}

sendSignerCompletionEmail — New Function in signing-mailer.tsx

// Source: signing-mailer.tsx existing pattern; CONTEXT.md D-12
export async function sendSignerCompletionEmail(opts: {
  to: string;
  signerName?: string;
  documentName: string;
  downloadUrl: string;
  expiresAt: Date;
}): Promise<void> {
  const transporter = createTransporter();
  await transporter.sendMail({
    from: '"Teressa Copeland Homes" <teressa@tcopelandhomes.com>',
    to: opts.to,
    subject: `Signed copy ready: ${opts.documentName}`,
    text: [
      `All parties have signed "${opts.documentName}".`,
      `Download your copy here (link expires ${opts.expiresAt.toLocaleDateString('en-US')}):`,
      opts.downloadUrl,
    ].join('\n\n'),
  });
}

Files Being Changed (from CONTEXT.md canonical refs)

File Change Type What Changes
src/lib/signing/token.ts Extend Add optional signerEmail? param to createSigningToken; add createSignerDownloadToken / verifySignerDownloadToken
src/app/api/documents/[id]/send/route.ts Rewrite Legacy fallback (D-01) + multi-signer token loop (D-02/D-03)
src/app/api/sign/[token]/route.ts Rewrite GET: signer-aware field filter (D-04/D-05/D-06); POST: accumulate + completion guard (D-07 through D-09)
src/lib/signing/signing-mailer.tsx Add function sendSignerCompletionEmail (D-12)
src/app/api/sign/download/[token]/route.ts New file Public signer download route (D-11)

State of the Art (Phase 14 outputs confirmed in schema.ts)

The following are confirmed present in the codebase from Phase 14 inspection:

Column / Type Location Confirmed
signingTokens.signerEmail (nullable TEXT) schema.ts line 140 YES
documents.signers (nullable JSONB DocumentSigner[]) schema.ts line 113 YES
documents.completionTriggeredAt (nullable TIMESTAMP) schema.ts line 115 YES
DocumentSigner interface { email: string; color: string } schema.ts line 87-90 YES
getSignerEmail(field, fallback) helper schema.ts line 49-51 YES
SignatureFieldData.signerEmail? schema.ts line 21 YES

Note: DocumentSigner has { email, color } — NO tokenJti or signedAt fields. The ARCHITECTURE.md described these fields but they were NOT added in Phase 14 (schema.ts is authoritative). The send route cannot store tokenJti back into doc.signers[] unless Phase 15 adds an UPDATE after token creation. This is a discretion area — the planner should decide whether to update doc.signers[n].tokenJti at send time.

Also note: auditEventTypeEnum in schema.ts lines 126-133 still only has the original 6 values (document_prepared, email_sent, link_opened, document_viewed, signature_submitted, pdf_hash_computed). The new audit event types (signer_email_sent, signer_signed, document_completed) from the ARCHITECTURE.md research were NOT added in Phase 14. Phase 15 must either (a) use existing email_sent / signature_submitted events with metadata: { signerEmail } to avoid a schema migration, or (b) include a tiny migration to add the new enum values. Since CONTEXT.md says "No schema migrations" for Phase 15, approach (a) — use existing event types with metadata — is the correct path.


Environment Availability

Step 2.6: SKIPPED — this phase is purely code changes to existing routes with no new external dependencies. All required tools (Node.js, the DB connection via Neon, SMTP) are already operational.


Validation Architecture

workflow.nyquist_validation is not set in .planning/config.json — treating as enabled.

Test Framework

Property Value
Framework Jest + ts-jest
Config file None found — jest.config.ts does not exist at project root; @types/jest and jest are devDependencies
Quick run command cd teressa-copeland-homes && npx jest --testPathPattern=sign
Full suite command cd teressa-copeland-homes && npx jest

One test file exists: src/lib/pdf/__tests__/prepare-document.test.ts (Y-flip coordinate unit tests, no relation to signing flow).

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
MSIGN-05 doc.signers drives token creation unit npx jest --testPathPattern=send No — Wave 0
MSIGN-06 All tokens created, all emails dispatched in parallel unit npx jest --testPathPattern=send No — Wave 0
MSIGN-07 GET /sign/[token] returns only signer-scoped fields unit npx jest --testPathPattern=sign-get No — Wave 0
MSIGN-10 Agent notification fires only when all tokens claimed unit npx jest --testPathPattern=sign-post No — Wave 0
MSIGN-11 Signer completion emails + download route unit npx jest --testPathPattern=sign-post No — Wave 0

Sampling Rate

  • Per task commit: cd teressa-copeland-homes && npx jest --testPathPattern=signing
  • Per wave merge: cd teressa-copeland-homes && npx jest
  • Phase gate: Full suite green before /gsd:verify-work

Wave 0 Gaps

  • src/app/api/documents/[id]/send/__tests__/send-route.test.ts — covers MSIGN-05, MSIGN-06 (mock createSigningToken, sendSigningRequestEmail; verify Promise.all dispatch count)
  • src/app/api/sign/[token]/__tests__/sign-get.test.ts — covers MSIGN-07 (mock DB rows; verify filtered vs unfiltered field arrays)
  • src/app/api/sign/[token]/__tests__/sign-post.test.ts — covers MSIGN-10, MSIGN-11 (mock atomic claim, count query, completionTriggeredAt race)
  • jest.config.ts — no jest config file exists at project root; ts-jest is installed but unconfigured

Open Questions

  1. Advisory lock vs unique partial paths — which approach for accumulate?

    • What we know: ARCHITECTURE.md recommends pg_advisory_xact_lock. The Context (D-10) uses signedFilePath as a serial working copy, implying the lock is needed to prevent read-modify-write races.
    • What's unclear: Whether Drizzle's db.execute(sql...) can run advisory lock SQL without being inside an explicit transaction — pg_advisory_xact_lock requires a transaction to be meaningful.
    • Recommendation: Use unique JTI-keyed output paths for partial PDFs (no lock needed for writes). Use completionTriggeredAt guard (already proven) for the completion race. Advisory lock only needed if the final completion handler reads and processes intermediate partials serially — which it doesn't in the D-10 design.
  2. DocumentSigner lacks tokenJti — should send route update the signers array?

    • What we know: schema.ts DocumentSigner = { email, color }. ARCHITECTURE.md described tokenJti as a field but it was not implemented in Phase 14.
    • What's unclear: Whether the planner should add a DB update to store tokenJti on each signer after token creation (useful for resend functionality later).
    • Recommendation: Skip for Phase 15. Phase 16 (dashboard per-signer status) can query signingTokens by signerEmail + documentId to find the token — no denormalization needed. Add as discretion item in PLAN.md notes.
  3. APP_BASE_URL rename — is Phase 15 the right place?

    • What we know: send/route.ts uses NEXT_PUBLIC_BASE_URL which bakes at build time (Pitfall 5). Phase 17 is the Docker phase. The variable is read only in server-side route handlers — it should never have the NEXT_PUBLIC_ prefix.
    • What's unclear: Whether to fix this in Phase 15 (touching send/route.ts anyway) or defer to Phase 17.
    • Recommendation: Fix it in Phase 15 since the route is being rewritten. Change process.env.NEXT_PUBLIC_BASE_URL to process.env.APP_BASE_URL in send/route.ts and note in Phase 17 that the env var name has already been updated.

Project Constraints (from AGENTS.md / CLAUDE.md)

CLAUDE.md at teressa-copeland-homes/CLAUDE.md reads @AGENTS.md. AGENTS.md states:

This is NOT the Next.js you know. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.

Implication for planning: Implementation tasks must include a directive to read node_modules/next/dist/docs/ before writing any new route files. Specifically relevant for the new GET /api/sign/download/[token] route and any changes to the App Router route handler signatures.


Sources

Primary (HIGH confidence)

  • Direct codebase inspection: src/app/api/sign/[token]/route.ts — exact current implementation of GET and POST handlers
  • Direct codebase inspection: src/app/api/documents/[id]/send/route.ts — exact current implementation
  • Direct codebase inspection: src/lib/signing/token.ts — JWT utility functions, exact signatures
  • Direct codebase inspection: src/lib/signing/signing-mailer.tsx — email utility, createTransporter pattern
  • Direct codebase inspection: src/lib/signing/embed-signature.tsembedSignatureInPdf signature and atomic rename pattern
  • Direct codebase inspection: src/lib/db/schema.ts — Phase 14 schema additions confirmed present
  • .planning/phases/15-multi-signer-backend/15-CONTEXT.md — all locked implementation decisions D-01 through D-12

Secondary (MEDIUM confidence)

  • .planning/research/ARCHITECTURE.md — multi-signer data flow, advisory lock pattern, completion sequence
  • .planning/research/PITFALLS.md — first-signer-wins bug analysis (line references verified against actual code), race condition pattern

Notes

  • DocumentSigner interface discrepancy noted: ARCHITECTURE.md described tokenJti and signedAt fields; actual schema.ts has only { email, color }. Code references in RESEARCH.md use the authoritative schema.ts definition.
  • auditEventTypeEnum discrepancy noted: ARCHITECTURE.md described new enum values; actual schema.ts has only the original 6. Phase 15 uses existing email_sent/signature_submitted events with metadata to avoid requiring a schema migration.

Metadata

Confidence breakdown:

  • Current code structure: HIGH — all three routes and utilities read directly
  • Schema completeness (Phase 14 output): HIGH — schema.ts verified line by line
  • Completion guard pattern: HIGH — identical pattern already exists for token claim
  • Accumulate PDF strategy: HIGH — embedSignatureInPdf verified to accept any input path
  • signer-download JWT: HIGH — identical to existing createAgentDownloadToken pattern
  • Advisory lock recommendation: MEDIUM — db.execute(sql...) for raw SQL not verified against this Next.js version; alternative (unique output paths) is safer and equally correct

Research date: 2026-04-03 Valid until: 2026-05-03 (schema is stable; JWT library unlikely to change)