12 KiB
12 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 | 02 | execute | 2 |
|
|
true |
|
|
Purpose: This is the entry point for multi-signer flow — without this, documents can only be sent to one recipient. Output: Rewritten send/route.ts with multi-signer token loop + legacy fallback.
<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/documents/[id]/send/route.ts @teressa-copeland-homes/src/lib/signing/token.ts @teressa-copeland-homes/src/lib/db/schema.ts
```typescript export async function createSigningToken(documentId: string, signerEmail?: string): Promise<{ token: string; jti: string; expiresAt: Date }>; ```export interface DocumentSigner { email: string; color: string; }
// documents.signers is jsonb().$type<DocumentSigner[]>() — nullable
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> })
import { createSigningToken } from '@/lib/signing/token';
import { sendSigningRequestEmail } from '@/lib/signing/signing-mailer';
import { logAuditEvent } from '@/lib/signing/audit';
import { documents, clients } from '@/lib/db/schema';
**Keep unchanged:** auth guard, doc fetch, preparedFilePath guard, status === 'Signed' guard, error boundary try/catch.
**Change 1 — APP_BASE_URL (Pitfall 5, per quality gate):** Replace `process.env.NEXT_PUBLIC_BASE_URL` with `process.env.APP_BASE_URL` on the baseUrl line. Keep `?? 'http://localhost:3000'` fallback.
**Change 2 — Branching logic (D-01, D-02):** After the doc fetch and guards, branch on `doc.signers && doc.signers.length > 0`:
**Multi-signer path (D-02):**
```typescript
const signers = doc.signers as DocumentSigner[];
const baseUrl = process.env.APP_BASE_URL ?? 'http://localhost:3000';
await Promise.all(
signers.map(async (signer) => {
const { token, expiresAt } = await createSigningToken(doc.id, signer.email);
const signingUrl = `${baseUrl}/sign/${token}`;
await sendSigningRequestEmail({
to: signer.email,
documentName: doc.name,
signingUrl,
expiresAt,
});
await logAuditEvent({
documentId: doc.id,
eventType: 'email_sent',
metadata: { signerEmail: signer.email },
});
})
);
```
Note: `sendSigningRequestEmail` has an optional `clientName` param. In multi-signer mode, we do NOT have a per-signer name (DocumentSigner is `{ email, color }` only). Omitting `clientName` is fine — the email template handles it gracefully.
**Legacy single-signer path (D-01):** Keep the existing logic verbatim — resolve clientId, fetch client, create single token (no signerEmail), send email with client.name, log audit event. Only change is `APP_BASE_URL` instead of `NEXT_PUBLIC_BASE_URL`.
**After branch:** Both paths converge — update status to 'Sent' if currently 'Draft' (existing logic, unchanged). Return `{ ok: true, expiresAt }` — for multi-signer, use the last signer's expiresAt or just return `ok: true` without expiresAt (all tokens have the same 72h TTL anyway).
**Import changes:** Add `DocumentSigner` import from schema if not already imported.
**Error handling (Claude's Discretion):** Individual signer email failure should not block the entire send. Wrap each signer's map callback in try/catch — if one email fails, the others still send. Collect errors and return 207 Multi-Status if partial failure, or 502 if all fail. Alternatively, keep it simple: let Promise.all fail if any email fails (current behavior for single signer). Decision: keep Promise.all as-is for now (simple, consistent with existing pattern). If one email fails, the entire send fails and the agent retries.
Complete rewrite:
```typescript
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents, clients, DocumentSigner } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { createSigningToken } from '@/lib/signing/token';
import { sendSigningRequestEmail } from '@/lib/signing/signing-mailer';
import { logAuditEvent } from '@/lib/signing/audit';
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
try {
const doc = await db.query.documents.findFirst({
where: eq(documents.id, id),
});
if (!doc) return NextResponse.json({ error: 'Document not found' }, { status: 404 });
if (!doc.preparedFilePath)
return NextResponse.json({ error: 'Document not yet prepared' }, { status: 422 });
if (doc.status === 'Signed')
return NextResponse.json({ error: 'Already signed' }, { status: 409 });
const baseUrl = process.env.APP_BASE_URL ?? 'http://localhost:3000';
if (doc.signers && (doc.signers as DocumentSigner[]).length > 0) {
// ── Multi-signer path (D-02) ──
const signers = doc.signers as DocumentSigner[];
await Promise.all(
signers.map(async (signer) => {
const { token, expiresAt } = await createSigningToken(doc.id, signer.email);
const signingUrl = `${baseUrl}/sign/${token}`;
await sendSigningRequestEmail({
to: signer.email,
documentName: doc.name,
signingUrl,
expiresAt,
});
await logAuditEvent({
documentId: doc.id,
eventType: 'email_sent',
metadata: { signerEmail: signer.email },
});
})
);
} else {
// ── Legacy single-signer path (D-01) ──
const clientId = doc.assignedClientId ?? doc.clientId;
const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
if (!client) return NextResponse.json({ error: 'Client not found' }, { status: 422 });
const { token, expiresAt } = await createSigningToken(doc.id);
const signingUrl = `${baseUrl}/sign/${token}`;
await sendSigningRequestEmail({
to: client.email,
clientName: client.name,
documentName: doc.name,
signingUrl,
expiresAt,
});
await logAuditEvent({ documentId: doc.id, eventType: 'email_sent' });
}
// Update status to Sent (skip if already Sent or Signed to avoid downgrade)
if (doc.status === 'Draft') {
await db
.update(documents)
.set({ status: 'Sent', sentAt: new Date() })
.where(eq(documents.id, id));
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[send] error:', err);
return NextResponse.json({ error: 'Failed to send signing email' }, { status: 502 });
}
}
```
cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
- grep confirms APP_BASE_URL: `grep "APP_BASE_URL" src/app/api/documents/\[id\]/send/route.ts`
- grep confirms NO NEXT_PUBLIC_BASE_URL: `grep -c "NEXT_PUBLIC_BASE_URL" src/app/api/documents/\[id\]/send/route.ts` returns 0
- grep confirms multi-signer branch: `grep "doc.signers" src/app/api/documents/\[id\]/send/route.ts`
- grep confirms Promise.all: `grep "Promise.all" src/app/api/documents/\[id\]/send/route.ts`
- grep confirms createSigningToken with signerEmail: `grep "createSigningToken(doc.id, signer.email)" src/app/api/documents/\[id\]/send/route.ts`
- grep confirms legacy path still uses createSigningToken without signerEmail: `grep "createSigningToken(doc.id)" src/app/api/documents/\[id\]/send/route.ts`
- grep confirms metadata with signerEmail in audit: `grep "signerEmail: signer.email" src/app/api/documents/\[id\]/send/route.ts`
- grep confirms DocumentSigner import: `grep "DocumentSigner" src/app/api/documents/\[id\]/send/route.ts`
- `npx tsc --noEmit` passes with zero errors
Send route creates one token per signer with signerEmail written to DB, dispatches all emails in parallel. Legacy single-signer path preserved unchanged. APP_BASE_URL replaces NEXT_PUBLIC_BASE_URL.
`cd teressa-copeland-homes && npx tsc --noEmit` passes
Send route has both multi-signer and legacy branches
APP_BASE_URL used instead of NEXT_PUBLIC_BASE_URL
<success_criteria>
- Multi-signer documents:
doc.signers = [{email: 'a@x.com', color: '#f00'}, {email: 'b@x.com', color: '#0f0'}]results in 2 tokens created, 2 emails sent, 2 audit events - Legacy documents:
doc.signers = nullresults in 1 token (no signerEmail), 1 email to assigned client APP_BASE_URLis the env var used for signing URLs (notNEXT_PUBLIC_BASE_URL)npx tsc --noEmitpasses </success_criteria>