291 lines
14 KiB
Markdown
291 lines
14 KiB
Markdown
|
|
---
|
||
|
|
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>
|