docs(06-signing-flow): create phase plan

This commit is contained in:
Chandler Copeland
2026-03-20 11:18:47 -06:00
parent d049f92c61
commit 6cf228c779
7 changed files with 1956 additions and 3 deletions

View File

@@ -0,0 +1,345 @@
---
phase: 06-signing-flow
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- teressa-copeland-homes/src/lib/db/schema.ts
- teressa-copeland-homes/drizzle/0005_signing_flow.sql
- teressa-copeland-homes/src/lib/signing/token.ts
- teressa-copeland-homes/src/lib/signing/audit.ts
- teressa-copeland-homes/src/lib/signing/embed-signature.ts
- teressa-copeland-homes/package.json
autonomous: true
requirements:
- SIGN-02
- LEGAL-01
- LEGAL-02
must_haves:
truths:
- "signingTokens table exists with jti, documentId, expiresAt, usedAt columns"
- "auditEvents table exists with all 6 event type enum values"
- "documents table has signedFilePath, pdfHash, signedAt columns"
- "createSigningToken() returns a JWT with documentId and jti claim; jti stored in signingTokens"
- "verifySigningToken() throws on expired or invalid JWT"
- "logAuditEvent() inserts a row into auditEvents with server-side timestamp"
- "embedSignatureInPdf() embeds PNG into prepared PDF at signatureFields coordinates, returns SHA-256 hex"
- "npm run build passes cleanly"
artifacts:
- path: "teressa-copeland-homes/src/lib/db/schema.ts"
provides: "signingTokens table, auditEvents table + enum, 3 new documents columns"
contains: "signingTokens"
- path: "teressa-copeland-homes/drizzle/0005_signing_flow.sql"
provides: "Applied migration adding signing tables"
- path: "teressa-copeland-homes/src/lib/signing/token.ts"
provides: "createSigningToken(), verifySigningToken()"
exports: ["createSigningToken", "verifySigningToken"]
- path: "teressa-copeland-homes/src/lib/signing/audit.ts"
provides: "logAuditEvent()"
exports: ["logAuditEvent"]
- path: "teressa-copeland-homes/src/lib/signing/embed-signature.ts"
provides: "embedSignatureInPdf()"
exports: ["embedSignatureInPdf"]
key_links:
- from: "token.ts"
to: "signingTokens table"
via: "jti stored in DB on createSigningToken"
pattern: "db.insert\\(signingTokens\\)"
- from: "embed-signature.ts"
to: "uploads/ directory"
via: "reads preparedFilePath, writes signedFilePath with atomic rename"
pattern: "rename\\(tmpPath"
user_setup:
- service: signing-jwt
why: "JWT secret for signing link tokens"
env_vars:
- name: SIGNING_JWT_SECRET
source: "Generate a random 32+ character string — e.g.: openssl rand -base64 32"
---
<objective>
Lay the cryptographic and data foundation for the entire Phase 6 signing flow: database tables, server utilities, and npm packages.
Purpose: Everything in plans 02-05 depends on these primitives — token creation, audit logging, and PDF signature embedding. Must be in place first.
Output: signingTokens table, auditEvents table, 3 new documents columns, createSigningToken/verifySigningToken/logAuditEvent/embedSignatureInPdf server utilities, signature_pad and @react-email packages installed.
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-signing-flow/06-CONTEXT.md
@.planning/phases/06-signing-flow/06-RESEARCH.md
@.planning/phases/05-pdf-fill-and-field-mapping/05-01-SUMMARY.md
<interfaces>
<!-- Current schema.ts ends with these exports — new tables must be added after. -->
From teressa-copeland-homes/src/lib/db/schema.ts (existing):
```typescript
export interface SignatureFieldData {
id: string;
page: number; // 1-indexed
x: number; // PDF user space, bottom-left origin, points
y: number; // PDF user space, bottom-left origin, points
width: number; // PDF points
height: number; // PDF points
}
// documents table already has these columns (Phase 5):
// signatureFields: jsonb.$type<SignatureFieldData[]>()
// preparedFilePath: text — absolute path to the prepared PDF
// assignedClientId: text
```
Most recent migration file is 0004_military_maximus.sql — next migration must be 0005_*.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Install packages + extend schema + generate migration</name>
<files>
teressa-copeland-homes/package.json
teressa-copeland-homes/src/lib/db/schema.ts
teressa-copeland-homes/drizzle/0005_signing_flow.sql
</files>
<action>
Install new packages from within teressa-copeland-homes/:
```bash
cd teressa-copeland-homes && npm install signature_pad @react-email/render @react-email/components
```
(jose, @cantoo/pdf-lib, nodemailer, react-pdf are already installed — do NOT reinstall them)
Extend src/lib/db/schema.ts — add after the existing documentsRelations export:
1. Add a new pgEnum for audit event types:
```typescript
export const auditEventTypeEnum = pgEnum('audit_event_type', [
'document_prepared',
'email_sent',
'link_opened',
'document_viewed',
'signature_submitted',
'pdf_hash_computed',
]);
```
2. Add signingTokens table:
```typescript
export const signingTokens = pgTable('signing_tokens', {
jti: text('jti').primaryKey(),
documentId: text('document_id').notNull()
.references(() => documents.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at').notNull(),
usedAt: timestamp('used_at'),
});
```
3. Add auditEvents table:
```typescript
export const auditEvents = pgTable('audit_events', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
documentId: text('document_id').notNull()
.references(() => documents.id, { onDelete: 'cascade' }),
eventType: auditEventTypeEnum('event_type').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
```
4. Add three new columns to the existing documents pgTable definition (add alongside the existing preparedFilePath column):
```typescript
signedFilePath: text('signed_file_path'),
pdfHash: text('pdf_hash'),
signedAt: timestamp('signed_at'),
```
Generate migration with drizzle-kit (run from teressa-copeland-homes/):
```bash
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/teressa npx drizzle-kit generate
```
This creates drizzle/0005_*.sql. Rename it to 0005_signing_flow.sql for clarity.
Apply migration:
```bash
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/teressa npx drizzle-kit migrate
```
Verify migration applied: `psql postgresql://postgres:postgres@localhost:5432/teressa -c "\dt"` should show signing_tokens and audit_events tables.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && DATABASE_URL=postgresql://postgres:postgres@localhost:5432/teressa psql postgresql://postgres:postgres@localhost:5432/teressa -c "\dt" 2>/dev/null | grep -E "signing_tokens|audit_events"</automated>
</verify>
<done>Both signing_tokens and audit_events tables appear in \dt output; npm run build compiles with no TypeScript errors for schema.ts</done>
</task>
<task type="auto">
<name>Task 2: Create signing utility library (token + audit + embed)</name>
<files>
teressa-copeland-homes/src/lib/signing/token.ts
teressa-copeland-homes/src/lib/signing/audit.ts
teressa-copeland-homes/src/lib/signing/embed-signature.ts
</files>
<action>
Create directory: teressa-copeland-homes/src/lib/signing/
**token.ts** — JWT token creation and verification using jose (already installed):
```typescript
import { SignJWT, jwtVerify } from 'jose';
import { db } from '@/lib/db';
import { signingTokens } from '@/lib/db/schema';
const getSecret = () => new TextEncoder().encode(process.env.SIGNING_JWT_SECRET!);
export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }> {
const jti = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000); // 72 hours
const token = await new SignJWT({ documentId, purpose: 'sign' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('72h')
.setJti(jti)
.sign(getSecret());
// Store token metadata for one-time-use enforcement
await db.insert(signingTokens).values({
jti,
documentId,
expiresAt,
});
return { token, jti, expiresAt };
}
export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }> {
// Throws JWTExpired or JWTInvalid on failure — caller handles
const { payload } = await jwtVerify(token, getSecret());
return payload as { documentId: string; jti: string; exp: number };
}
```
**audit.ts** — server-side audit event logging:
```typescript
import { db } from '@/lib/db';
import { auditEvents, auditEventTypeEnum } from '@/lib/db/schema';
type AuditEventType = typeof auditEventTypeEnum.enumValues[number];
export async function logAuditEvent(opts: {
documentId: string;
eventType: AuditEventType;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
}): Promise<void> {
await db.insert(auditEvents).values({
documentId: opts.documentId,
eventType: opts.eventType,
ipAddress: opts.ipAddress ?? null,
userAgent: opts.userAgent ?? null,
metadata: opts.metadata ?? null,
// createdAt is defaultNow() — server-side only, never from client
});
}
```
**embed-signature.ts** — PDF signature embedding with SHA-256 hash (LEGAL-02):
```typescript
import { PDFDocument } from '@cantoo/pdf-lib';
import { readFile, writeFile, rename } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
export interface SignatureToEmbed {
fieldId: string;
dataURL: string; // 'data:image/png;base64,...' from signature_pad or typed canvas
x: number; // PDF user space (bottom-left origin, points) — from signatureFields
y: number;
width: number;
height: number;
page: number; // 1-indexed
}
export async function embedSignatureInPdf(
preparedPdfPath: string, // absolute path — ALWAYS use doc.preparedFilePath, NOT filePath
signedPdfPath: string, // absolute path to write signed output (uploads/clients/{id}/{uuid}_signed.pdf)
signatures: SignatureToEmbed[]
): Promise<string> { // returns SHA-256 hex digest (LEGAL-02)
const pdfBytes = await readFile(preparedPdfPath);
const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
for (const sig of signatures) {
const page = pages[sig.page - 1];
if (!page) continue;
const pngImage = await pdfDoc.embedPng(sig.dataURL); // accepts base64 DataURL directly
page.drawImage(pngImage, {
x: sig.x,
y: sig.y,
width: sig.width,
height: sig.height,
});
}
const modifiedBytes = await pdfDoc.save();
const tmpPath = `${signedPdfPath}.tmp`;
await writeFile(tmpPath, modifiedBytes);
await rename(tmpPath, signedPdfPath); // atomic rename prevents corruption on partial write
// LEGAL-02: SHA-256 hash of final signed PDF — computed from disk after rename
return hashFile(signedPdfPath);
}
function hashFile(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('sha256');
createReadStream(filePath)
.on('data', (chunk) => hash.update(chunk))
.on('end', () => resolve(hash.digest('hex')))
.on('error', reject);
});
}
```
Add SIGNING_JWT_SECRET to teressa-copeland-homes/.env.local:
```
SIGNING_JWT_SECRET=replace_with_output_of_openssl_rand_base64_32
```
(Use a real 32-character random string — generate with: `openssl rand -base64 32`)
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5</automated>
</verify>
<done>All three utility files exist; npm run build passes with no TypeScript errors; SIGNING_JWT_SECRET placeholder added to .env.local</done>
</task>
</tasks>
<verification>
- Both new tables in PostgreSQL: `psql ... -c "\dt" | grep -E "signing_tokens|audit_events"`
- New documents columns visible: `psql ... -c "\d documents" | grep -E "signed_file|pdf_hash|signed_at"`
- Build passes: `npm run build` exits 0
- Utility files exist at expected paths in src/lib/signing/
</verification>
<success_criteria>
Phase 6 foundation is in place when: signingTokens and auditEvents tables exist in PostgreSQL (migration 0005 applied), documents has signedFilePath/pdfHash/signedAt columns, all three signing utility files compile without error, npm run build passes, and SIGNING_JWT_SECRET is in .env.local.
</success_criteria>
<output>
After completion, create `.planning/phases/06-signing-flow/06-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,384 @@
---
phase: 06-signing-flow
plan: "02"
type: execute
wave: 2
depends_on:
- "06-01"
files_modified:
- teressa-copeland-homes/src/emails/SigningRequestEmail.tsx
- teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
- teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts
- teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts
autonomous: true
requirements:
- SIGN-01
- LEGAL-01
must_haves:
truths:
- "POST /api/documents/[id]/send creates a signing token, stores jti in signingTokens, sends a branded HTML email with the signing URL, logs email_sent audit event, and updates document status to Sent"
- "The email sender is 'Teressa Copeland <teressa@teressacopelandhomes.com>'"
- "The email body includes document name, expiry date, and a prominent CTA button linking to /sign/[token]"
- "Agent receives notification email when client completes signing (logged on POST /api/sign/[token] completion — wired in plan 04)"
- "POST /api/documents/[id]/prepare now logs a document_prepared audit event"
- "npm run build passes cleanly"
artifacts:
- path: "teressa-copeland-homes/src/emails/SigningRequestEmail.tsx"
provides: "Branded React Email component for signing request"
exports: ["SigningRequestEmail"]
- path: "teressa-copeland-homes/src/lib/signing/signing-mailer.tsx"
provides: "sendSigningRequestEmail(), sendAgentNotificationEmail()"
exports: ["sendSigningRequestEmail", "sendAgentNotificationEmail"]
- path: "teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts"
provides: "POST handler: create token, send email, log audit event, update status"
key_links:
- from: "send/route.ts"
to: "createSigningToken() in token.ts"
via: "import and call to create JWT + DB row"
- from: "send/route.ts"
to: "logAuditEvent() in audit.ts"
via: "logs email_sent after sendMail resolves"
- from: "signing-mailer.tsx"
to: "SigningRequestEmail.tsx"
via: "@react-email/render renders component to HTML string"
- from: "prepare/route.ts"
to: "logAuditEvent()"
via: "logs document_prepared event after successful PDF preparation"
---
<objective>
Implement the email delivery layer: branded signing request email, agent notification, and the POST /api/documents/[id]/send endpoint that kicks off the signing ceremony.
Purpose: SIGN-01 requires clients receive a unique link via email with no account required. LEGAL-01 requires logging document_prepared and email_sent audit events.
Output: Branded HTML email component (React Email), signing-mailer utilities, send API route, and document_prepared audit logging added to existing prepare route.
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/06-signing-flow/06-CONTEXT.md
@.planning/phases/06-signing-flow/06-RESEARCH.md
@.planning/phases/06-signing-flow/06-01-SUMMARY.md
<interfaces>
<!-- From Plan 06-01 — signing utilities now available -->
From teressa-copeland-homes/src/lib/signing/token.ts:
```typescript
export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }>
```
From teressa-copeland-homes/src/lib/signing/audit.ts:
```typescript
type AuditEventType = 'document_prepared' | 'email_sent' | 'link_opened' | 'document_viewed' | 'signature_submitted' | 'pdf_hash_computed';
export async function logAuditEvent(opts: {
documentId: string;
eventType: AuditEventType;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
}): Promise<void>
```
From existing prepare route (teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts):
- POST handler that calls preparePdf(), updates preparedFilePath/textFillData/assignedClientId/status to Sent
- Currently does NOT log any audit events — Task 2 adds document_prepared logging here
From existing contact-mailer.ts (SMTP pattern reference):
```typescript
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.CONTACT_SMTP_HOST,
port: Number(process.env.CONTACT_SMTP_PORT ?? 587),
secure: false,
auth: { user: process.env.CONTACT_EMAIL_USER, pass: process.env.CONTACT_EMAIL_PASS },
});
```
Reuse the same CONTACT_SMTP_* env vars for the signing email — same SMTP provider.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Branded signing request email + mailer</name>
<files>
teressa-copeland-homes/src/emails/SigningRequestEmail.tsx
teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
</files>
<action>
Create directory: teressa-copeland-homes/src/emails/
**src/emails/SigningRequestEmail.tsx** — React Email component for signing request:
The email must match locked decisions from CONTEXT.md:
- Sender name: "Teressa Copeland"
- Body: document name, expiry deadline, instruction "No account needed — just click the button below"
- Prominent "Review & Sign" CTA button
Use @react-email/components primitives (Html, Head, Body, Container, Heading, Text, Button, Hr, Preview). Brand colors: navy #1B2B4B, gold #C9A84C, cream/white background.
```typescript
// src/emails/SigningRequestEmail.tsx
import {
Html, Head, Body, Container, Heading, Text, Button, Hr, Preview
} from '@react-email/components';
interface SigningRequestEmailProps {
documentName: string;
signingUrl: string;
expiryDate: string; // e.g. "March 25, 2026"
clientName?: string;
}
export function SigningRequestEmail({ documentName, signingUrl, expiryDate, clientName }: SigningRequestEmailProps) {
return (
<Html>
<Head />
<Preview>Please review and sign: {documentName}</Preview>
<Body style={{ backgroundColor: '#ffffff', fontFamily: 'Georgia, serif' }}>
<Container style={{ maxWidth: '560px', margin: '40px auto', padding: '0 20px' }}>
<Heading style={{ color: '#1B2B4B', fontSize: '22px', marginBottom: '8px' }}>
Teressa Copeland Homes
</Heading>
<Hr style={{ borderColor: '#C9A84C', marginBottom: '24px' }} />
{clientName && (
<Text style={{ color: '#333', fontSize: '16px' }}>Hello {clientName},</Text>
)}
<Text style={{ color: '#333', fontSize: '16px', lineHeight: '1.6' }}>
You have a document ready for your review and signature:
</Text>
<Text style={{ color: '#1B2B4B', fontSize: '18px', fontWeight: 'bold', margin: '16px 0' }}>
{documentName}
</Text>
<Text style={{ color: '#555', fontSize: '15px' }}>
No account needed just click the button below.
</Text>
<Button
href={signingUrl}
style={{
backgroundColor: '#C9A84C',
color: '#ffffff',
padding: '14px 32px',
borderRadius: '4px',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
display: 'inline-block',
margin: '20px 0',
}}
>
Review &amp; Sign
</Button>
<Text style={{ color: '#888', fontSize: '13px' }}>
This link expires on {expiryDate}. If you did not expect this document, you can safely ignore this email.
</Text>
<Hr style={{ borderColor: '#e0e0e0', marginTop: '32px' }} />
<Text style={{ color: '#aaa', fontSize: '12px' }}>
Teressa Copeland Homes · Utah Licensed Real Estate Agent
</Text>
</Container>
</Body>
</Html>
);
}
```
Also create a minimal agent notification component inline in signing-mailer.tsx (no separate file needed — it is simpler).
**src/lib/signing/signing-mailer.tsx** — mailer functions. IMPORTANT: Must be .tsx extension (not .ts) because it calls render() with JSX:
```typescript
import { render } from '@react-email/render';
import nodemailer from 'nodemailer';
import { SigningRequestEmail } from '@/emails/SigningRequestEmail';
import React from 'react';
function createTransporter() {
return nodemailer.createTransport({
host: process.env.CONTACT_SMTP_HOST!,
port: Number(process.env.CONTACT_SMTP_PORT ?? 587),
secure: false,
auth: {
user: process.env.CONTACT_EMAIL_USER!,
pass: process.env.CONTACT_EMAIL_PASS!,
},
});
}
export async function sendSigningRequestEmail(opts: {
to: string;
clientName?: string;
documentName: string;
signingUrl: string;
expiresAt: Date;
}): Promise<void> {
const expiryDate = opts.expiresAt.toLocaleDateString('en-US', {
month: 'long', day: 'numeric', year: 'numeric',
});
const html = await render(
React.createElement(SigningRequestEmail, {
documentName: opts.documentName,
signingUrl: opts.signingUrl,
expiryDate,
clientName: opts.clientName,
})
);
const transporter = createTransporter();
await transporter.sendMail({
from: '"Teressa Copeland" <teressa@teressacopelandhomes.com>',
to: opts.to,
subject: `Please sign: ${opts.documentName}`,
html,
});
}
export async function sendAgentNotificationEmail(opts: {
clientName: string;
documentName: string;
signedAt: Date;
}): Promise<void> {
const formattedTime = opts.signedAt.toLocaleString('en-US', {
month: 'long', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit', timeZoneName: 'short',
});
const transporter = createTransporter();
await transporter.sendMail({
from: '"Teressa Copeland Homes" <teressa@teressacopelandhomes.com>',
to: 'teressa@teressacopelandhomes.com',
subject: `Signed: ${opts.documentName}`,
text: `${opts.clientName} has signed "${opts.documentName}" on ${formattedTime}.`,
});
}
```
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5</automated>
</verify>
<done>SigningRequestEmail.tsx and signing-mailer.tsx exist and compile; npm run build passes</done>
</task>
<task type="auto">
<name>Task 2: Send API route + document_prepared audit logging</name>
<files>
teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts
teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts
</files>
<action>
**Create src/app/api/documents/[id]/send/route.ts** — POST handler (agent-authenticated):
Logic:
1. Auth guard (same pattern as all portal API routes: `const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 })`)
2. Resolve params: `const { id } = await params` (Next.js 16 — params is a Promise)
3. Fetch document from DB — if not found return 404; if no preparedFilePath return 422 ("Document not yet prepared"); if status is 'Signed' return 409 ("Already signed")
4. Fetch client email from assignedClientId (fall back to doc.clientId for the email lookup)
5. Call `createSigningToken(doc.id)` — returns { token, jti, expiresAt }
6. Build signing URL: `${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/sign/${token}`
7. Call `sendSigningRequestEmail({ to: clientEmail, clientName, documentName: doc.name, signingUrl, expiresAt })`
8. Log audit event: `await logAuditEvent({ documentId: doc.id, eventType: 'email_sent' })`
9. Update document status to 'Sent', set sentAt = now() (only if not already Sent — use db.update with WHERE status != 'Signed')
10. Return 200 JSON: `{ ok: true, expiresAt: expiresAt.toISOString() }`
Error handling: wrap entire body in try/catch; if sendMail throws, return 502 with error message (do NOT update DB status if email failed).
```typescript
// src/app/api/documents/[id]/send/route.ts
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents, clients } 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 });
// Resolve recipient: prefer assignedClientId, fall back to clientId
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 baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000';
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, expiresAt: expiresAt.toISOString() });
} catch (err) {
console.error('[send] error:', err);
return NextResponse.json({ error: 'Failed to send signing email' }, { status: 502 });
}
}
```
**Modify src/app/api/documents/[id]/prepare/route.ts** — add document_prepared audit log after the existing preparePdf() + db.update() success path. Find the line where status is updated to 'Sent' and add immediately after:
```typescript
await logAuditEvent({ documentId: id, eventType: 'document_prepared' });
```
Import logAuditEvent at the top of prepare/route.ts:
```typescript
import { logAuditEvent } from '@/lib/signing/audit';
```
IMPORTANT: The prepare route already marks status 'Sent'. In the new signing flow, PreparePanel will call prepare (fills text + places rects) and then a separate "Send" button calls POST /api/documents/[id]/send. The document_prepared event must be logged in prepare/route.ts where the PDF is actually prepared — not in send/route.ts.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "error|Error|✓" | tail -10</automated>
</verify>
<done>send/route.ts exists; prepare/route.ts imports and calls logAuditEvent; npm run build passes with no TypeScript errors; curl POST /api/documents/{id}/send on an unauthenticated request returns 401</done>
</task>
</tasks>
<verification>
- Build passes: `npm run build` exits 0
- send route file exists: `ls teressa-copeland-homes/src/app/api/documents/*/send/route.ts`
- Email template: `ls teressa-copeland-homes/src/emails/SigningRequestEmail.tsx`
- prepare/route.ts references logAuditEvent: `grep logAuditEvent teressa-copeland-homes/src/app/api/documents/*/prepare/route.ts`
</verification>
<success_criteria>
Email delivery layer complete when: SigningRequestEmail.tsx renders without error, signing-mailer.tsx exports both functions, send/route.ts exists and returns 401 unauthenticated, prepare/route.ts logs document_prepared, npm run build passes.
</success_criteria>
<output>
After completion, create `.planning/phases/06-signing-flow/06-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,353 @@
---
phase: 06-signing-flow
plan: "03"
type: execute
wave: 2
depends_on:
- "06-01"
files_modified:
- teressa-copeland-homes/src/app/sign/[token]/page.tsx
- teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx
- teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx
- teressa-copeland-homes/src/app/api/sign/[token]/route.ts
autonomous: true
requirements:
- SIGN-02
- SIGN-03
- LEGAL-01
must_haves:
truths:
- "GET /sign/[token] renders the signing page for a valid, unused, unexpired token"
- "GET /sign/[token] renders a static 'Already signed' page (with signed date) for a used token — no canvas shown"
- "GET /sign/[token] renders a static 'Link expired' page for an expired JWT — no canvas shown"
- "The signing page shows the Teressa Copeland Homes header, document title, and instruction text"
- "The signing page renders the prepared PDF using react-pdf with all pages visible (full scroll)"
- "Signature fields are highlighted with a glowing/pulsing blue CSS outline overlay on the PDF"
- "A sticky progress bar shows 'X of Y signatures complete' with a jump-to-next button"
- "GET /api/sign/[token] validates token and returns document data; logs link_opened and document_viewed audit events"
- "npm run build passes cleanly"
artifacts:
- path: "teressa-copeland-homes/src/app/sign/[token]/page.tsx"
provides: "Server component — validates token, renders signing/error state"
- path: "teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx"
provides: "PDF viewer + field overlays + progress bar — client component"
- path: "teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx"
provides: "Sticky progress bar with jump-to-next field navigation"
- path: "teressa-copeland-homes/src/app/api/sign/[token]/route.ts"
provides: "GET: validate token, return doc data, log audit events"
key_links:
- from: "sign/[token]/page.tsx"
to: "verifySigningToken() + signingTokens DB lookup"
via: "server component validates before rendering any UI"
- from: "SigningPageClient.tsx"
to: "react-pdf Document + Page components"
via: "renders prepared PDF all pages scrollable"
- from: "SigningPageClient.tsx"
to: "signatureFields coordinates"
via: "absolutely positioned overlay divs with CSS animation on each field"
---
<objective>
Build the public signing page: server-side token validation with correct state rendering (signing/already-signed/expired), the react-pdf full-scroll PDF viewer with pulsing blue field highlights, and the sticky progress bar.
Purpose: SIGN-02 (one-time token enforcement shown to user) and SIGN-03 (prepared PDF with highlighted fields) — the visual signing ceremony surface.
Output: /sign/[token] public route with three states, PDF viewer component, pulsing field overlays, sticky progress bar, GET /api/sign/[token] data route.
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/06-signing-flow/06-CONTEXT.md
@.planning/phases/06-signing-flow/06-RESEARCH.md
@.planning/phases/06-signing-flow/06-01-SUMMARY.md
<interfaces>
<!-- From Plan 06-01 -->
From teressa-copeland-homes/src/lib/signing/token.ts:
```typescript
export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }>
// Throws JWTExpired or JWTInvalid on failure
```
From teressa-copeland-homes/src/lib/signing/audit.ts:
```typescript
export async function logAuditEvent(opts: {
documentId: string;
eventType: 'document_prepared' | 'email_sent' | 'link_opened' | 'document_viewed' | 'signature_submitted' | 'pdf_hash_computed';
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
}): Promise<void>
```
From teressa-copeland-homes/src/lib/db/schema.ts (relevant):
```typescript
export const signingTokens = pgTable('signing_tokens', {
jti: text('jti').primaryKey(),
documentId: text('document_id').notNull(),
expiresAt: timestamp('expires_at').notNull(),
usedAt: timestamp('used_at'), // NULL = unused
});
export interface SignatureFieldData {
id: string; page: number; x: number; y: number; width: number; height: number;
}
// documents.signatureFields: jsonb.$type<SignatureFieldData[]>()
// documents.preparedFilePath: text
// documents.signedAt: timestamp
```
Existing PdfViewer.tsx (Phase 4/5 — portal-only):
- Renders with react-pdf `Document` + `Page` components
- Uses `transpilePackages: ['react-pdf', 'pdfjs-dist']` in next.config.ts (already configured)
- Worker uses `new URL(import.meta.url)` pattern (already configured in PdfViewerWrapper.tsx)
- The signing page should build a SIMILAR but separate viewer — do NOT import the portal PdfViewer directly (it has portal-specific props and auth)
Middleware (middleware.ts):
- matcher: ["/agent/:path*", "/portal/:path*"]
- /sign/ is NOT in the matcher — it is public by default (no auth required)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: GET /api/sign/[token] route — validate token + audit logging</name>
<files>
teressa-copeland-homes/src/app/api/sign/[token]/route.ts
</files>
<action>
Create src/app/api/sign/[token]/route.ts — public GET route (no auth session required):
Logic:
1. Resolve token from params: `const { token } = await params`
2. Verify JWT: call `verifySigningToken(token)` — if it throws (expired/invalid), return appropriate JSON
3. Look up `jti` in signingTokens table — if `usedAt` is NOT NULL, return `{ status: 'used', signedAt: row.usedAt }`
4. Fetch document with `signatureFields`, `preparedFilePath`, `name` columns
5. Log `link_opened` event with IP and user-agent extracted from request headers (x-forwarded-for, user-agent)
6. Log `document_viewed` event (client opened the signing page — both events fire together on GET)
7. Return JSON: `{ status: 'pending', document: { id, name, signatureFields, preparedFilePath }, expiresAt }`
State return values:
- `{ status: 'expired' }` — JWT throws JWTExpired
- `{ status: 'invalid' }` — JWT throws anything else
- `{ status: 'used', signedAt: string }` — usedAt IS NOT NULL
- `{ status: 'pending', document: {...}, expiresAt: string }` — valid and unused
IP extraction:
```typescript
import { headers } from 'next/headers';
const hdrs = await headers();
const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() ?? hdrs.get('x-real-ip') ?? 'unknown';
const ua = hdrs.get('user-agent') ?? 'unknown';
```
Do NOT import or call `auth()` — this route is intentionally public.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign/\[token\]" | head -5</automated>
</verify>
<done>GET /api/sign/[token] exists and builds; returns appropriate JSON for expired/used/pending states</done>
</task>
<task type="auto">
<name>Task 2: Signing page server component + client PDF viewer + progress bar</name>
<files>
teressa-copeland-homes/src/app/sign/[token]/page.tsx
teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx
teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx
</files>
<action>
Create directory: src/app/sign/[token]/ and src/app/sign/[token]/_components/
**src/app/sign/[token]/page.tsx** — server component, validates token before rendering ANY UI (CRITICAL: no canvas flash on invalid tokens):
```typescript
import { verifySigningToken } from '@/lib/signing/token';
import { db } from '@/lib/db';
import { signingTokens, documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { SigningPageClient } from './_components/SigningPageClient';
interface Props {
params: Promise<{ token: string }>;
}
export default async function SignPage({ params }: Props) {
const { token } = await params;
// CRITICAL: Validate BEFORE rendering any signing UI
let payload: { documentId: string; jti: string } | null = null;
let isExpired = false;
try {
payload = await verifySigningToken(token);
} catch {
isExpired = true;
}
if (isExpired) {
return <ErrorPage type="expired" />;
}
if (!payload) {
return <ErrorPage type="invalid" />;
}
// Check one-time use
const tokenRow = await db.query.signingTokens.findFirst({
where: eq(signingTokens.jti, payload.jti),
});
if (!tokenRow) return <ErrorPage type="invalid" />;
if (tokenRow.usedAt !== null) {
return <ErrorPage type="used" signedAt={tokenRow.usedAt} />;
}
const doc = await db.query.documents.findFirst({
where: eq(documents.id, payload.documentId),
});
if (!doc || !doc.preparedFilePath) return <ErrorPage type="invalid" />;
return (
<SigningPageClient
token={token}
documentName={doc.name}
signatureFields={doc.signatureFields ?? []}
/>
);
}
function ErrorPage({ type, signedAt }: { type: 'expired' | 'used' | 'invalid'; signedAt?: Date | null }) {
const messages = {
expired: { title: 'Link Expired', body: 'This signing link has expired. Please contact Teressa Copeland for a new link.' },
used: {
title: 'Already Signed',
body: `This document has already been signed${signedAt ? ' on ' + signedAt.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : ''}.`,
},
invalid: { title: 'Invalid Link', body: 'This signing link is not valid. Please check your email for the correct link.' },
};
const { title, body } = messages[type];
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#FAF9F7', fontFamily: 'Georgia, serif' }}>
<div style={{ textAlign: 'center', maxWidth: '420px', padding: '40px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{type === 'used' ? '✓' : '⚠'}</div>
<h1 style={{ color: '#1B2B4B', fontSize: '24px', marginBottom: '12px' }}>{title}</h1>
<p style={{ color: '#555', fontSize: '16px', lineHeight: '1.6' }}>{body}</p>
</div>
</div>
);
}
```
**src/app/sign/[token]/_components/SigningProgressBar.tsx** — sticky progress bar (locked decision: sticky at bottom, "X of Y signatures complete" + jump-to-next):
```typescript
'use client';
interface SigningProgressBarProps {
total: number;
signed: number;
onJumpToNext: () => void;
onSubmit: () => void;
submitting: boolean;
}
export function SigningProgressBar({ total, signed, onJumpToNext, onSubmit, submitting }: SigningProgressBarProps) {
const allSigned = signed >= total;
return (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50,
backgroundColor: '#1B2B4B', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 24px', boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
}}>
<span style={{ fontSize: '15px' }}>
{signed} of {total} signature{total !== 1 ? 's' : ''} complete
</span>
<div style={{ display: 'flex', gap: '12px' }}>
{!allSigned && (
<button
onClick={onJumpToNext}
style={{ backgroundColor: 'transparent', border: '1px solid #C9A84C', color: '#C9A84C', padding: '8px 18px', borderRadius: '4px', cursor: 'pointer', fontSize: '14px' }}
>
Jump to Next
</button>
)}
<button
onClick={onSubmit}
disabled={!allSigned || submitting}
style={{
backgroundColor: allSigned ? '#C9A84C' : '#555',
color: '#fff', border: 'none', padding: '8px 22px', borderRadius: '4px',
cursor: allSigned ? 'pointer' : 'not-allowed', fontSize: '14px', fontWeight: 'bold',
opacity: submitting ? 0.7 : 1,
}}
>
{submitting ? 'Submitting...' : 'Submit Signature'}
</button>
</div>
</div>
);
}
```
**src/app/sign/[token]/_components/SigningPageClient.tsx** — main client component.
This is a 'use client' component that:
1. Shows branded page header: "Teressa Copeland Homes" + document title + "Please review and sign the document below."
2. Renders the prepared PDF via react-pdf (all pages in a vertical scroll) — use the same Document+Page pattern from PdfViewerWrapper.tsx but self-contained here
3. Renders absolutely-positioned overlay divs for each signature field with CSS `animation: pulse-border 2s infinite` — glowing blue outline (locked decision)
4. Tracks which fields have been signed in local state
5. Renders the sticky SigningProgressBar
6. When a signature field is clicked (and not yet signed), calls a prop `onFieldClick(fieldId)` to open the modal — modal is added in Plan 04
7. "Jump to Next" scrolls to the next unsigned field using `document.getElementById('field-'+fieldId)?.scrollIntoView`
8. Exports `signaturesRef` state (array of { fieldId, dataURL }) so Plan 04 can populate it
Key implementation notes:
- The PDF must be served from `/api/documents/[docId]/file` (existing authenticated route — but wait, /sign/ is public and that route requires agent auth). Instead, create a SEPARATE `/api/sign/[token]/pdf` route that validates the signing token and serves the prepared PDF file. Add this file: `src/app/api/sign/[token]/pdf/route.ts` — GET handler that validates the signing token (same token from URL, not usedAt check since client may still be viewing), reads the preparedFilePath from DB, and streams the file. This avoids exposing the file path publicly while keeping the signing page public.
- react-pdf requires `pdfjs-dist` worker — reuse the same `GlobalWorkerOptions.workerSrc = new URL(...)` pattern from the existing PdfViewerWrapper.tsx
- Add `<style>` tag with keyframes for the pulsing field animation:
```
@keyframes pulse-border {
0%, 100% { box-shadow: 0 0 0 2px #3b82f6, 0 0 8px 2px rgba(59,130,246,0.4); }
50% { box-shadow: 0 0 0 3px #3b82f6, 0 0 16px 4px rgba(59,130,246,0.6); }
}
```
- Field overlay positioning: each field div is `position: absolute` inside a relative container that wraps each Page. The PDF coordinates from signatureFields are in PDF user space (bottom-left origin). To convert to screen position for the overlay: the Y position from top = pageHeightPx - (field.y / pageHeightPts * pageHeightPx) - (field.height / pageHeightPts * pageHeightPx). Use the rendered page height. For simplicity, render all pages at a fixed 800px width; actual page height is computed from the Page's `onRenderSuccess` callback which provides the rendered dimensions.
The modal (SignatureModal) and submission POST are added in Plan 04. For now, the `onFieldClick` prop can be a no-op stub so the page compiles and renders.
Also create the PDF-serving route:
**src/app/api/sign/[token]/pdf/route.ts**:
- GET handler: validate signing token JWT (no usedAt check — just that it's a valid JWT for this doc), fetch doc.preparedFilePath from DB, read file with `readFile`, return as Response with `Content-Type: application/pdf`
- No agent auth required — authenticated by the signing token
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign" | head -10</automated>
</verify>
<done>/sign/[token]/page.tsx and all _components files exist and build cleanly; visiting /sign/[invalid-token] renders error page (not 500); build shows /sign/[token] as a dynamic route</done>
</task>
</tasks>
<verification>
- Build passes: `npm run build` exits 0
- Sign page files exist: `find teressa-copeland-homes/src/app/sign -name "*.tsx" | sort`
- Sign API routes exist: `find teressa-copeland-homes/src/app/api/sign -name "route.ts" | sort`
- Error states guard canvas: grep confirms `ErrorPage` rendered before any SigningPageClient on invalid tokens
</verification>
<success_criteria>
Signing page complete when: server component validates token before any UI renders, three error states (expired/used/invalid) show static pages with no canvas, valid token shows branded page header + PDF viewer + pulsing blue field overlays + sticky progress bar, and npm run build passes.
</success_criteria>
<output>
After completion, create `.planning/phases/06-signing-flow/06-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,400 @@
---
phase: 06-signing-flow
plan: "04"
type: execute
wave: 3
depends_on:
- "06-02"
- "06-03"
files_modified:
- teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx
- teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx
- teressa-copeland-homes/src/app/api/sign/[token]/route.ts
autonomous: true
requirements:
- SIGN-04
- SIGN-05
- LEGAL-01
- LEGAL-02
must_haves:
truths:
- "Clicking a pulsing blue signature field opens the SignatureModal"
- "Modal has three tabs: Draw (freehand canvas), Type (cursive font rendering), Use Saved (only if saved sig exists)"
- "Draw canvas uses signature_pad with devicePixelRatio scaling and touch-action: none — works on mobile and desktop"
- "Client can save a signature and it persists in localStorage; Use Saved tab shows it on subsequent fields"
- "After confirming in the modal, the field overlay changes from pulsing blue to a confirmed state (shows signature preview)"
- "Submit Signature button is only active when all fields are signed"
- "POST /api/sign/[token] atomically marks usedAt (UPDATE WHERE usedAt IS NULL), embeds all signatures via embedSignatureInPdf, stores pdfHash + signedFilePath, logs signature_submitted + pdf_hash_computed events, sends agent notification email, updates document status to Signed, redirects to /sign/[token]/confirmed"
- "If usedAt already set (race condition), POST returns 409 and no PDF is written"
- "npm run build passes cleanly"
artifacts:
- path: "teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx"
provides: "Draw/Type/Use Saved modal with signature_pad"
exports: ["SignatureModal"]
- path: "teressa-copeland-homes/src/app/api/sign/[token]/route.ts"
provides: "POST: atomic usedAt, embedSignatureInPdf, hash, audit events, redirect"
key_links:
- from: "SignatureModal.tsx"
to: "signature_pad library"
via: "useEffect mounts SignaturePad on canvas ref with devicePixelRatio scaling"
- from: "POST /api/sign/[token]"
to: "signingTokens table"
via: "UPDATE SET usedAt WHERE jti=? AND usedAt IS NULL RETURNING — atomic one-time enforcement"
- from: "POST /api/sign/[token]"
to: "embedSignatureInPdf() in embed-signature.ts"
via: "calls with preparedFilePath + all signature dataURLs + coordinates"
- from: "POST /api/sign/[token]"
to: "documents table pdfHash + signedAt columns"
via: "stores SHA-256 hash returned from embedSignatureInPdf"
---
<objective>
Implement the signature capture modal (Draw/Type/Use Saved) and the POST submission route that atomically commits the signed PDF with audit trail and SHA-256 hash.
Purpose: SIGN-04 (freehand canvas on mobile+desktop), SIGN-05 (saved signature), LEGAL-01 (final 4 audit events), LEGAL-02 (SHA-256 hash).
Output: SignatureModal with signature_pad, SigningPageClient wired to modal, POST /api/sign/[token] with atomic one-time-use enforcement and PDF embedding.
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/06-signing-flow/06-CONTEXT.md
@.planning/phases/06-signing-flow/06-RESEARCH.md
@.planning/phases/06-signing-flow/06-01-SUMMARY.md
@.planning/phases/06-signing-flow/06-03-SUMMARY.md
<interfaces>
<!-- From Plan 06-01 -->
From teressa-copeland-homes/src/lib/signing/embed-signature.ts:
```typescript
export interface SignatureToEmbed {
fieldId: string;
dataURL: string; // 'data:image/png;base64,...'
x: number; y: number; width: number; height: number;
page: number; // 1-indexed
}
export async function embedSignatureInPdf(
preparedPdfPath: string,
signedPdfPath: string,
signatures: SignatureToEmbed[]
): Promise<string> // returns SHA-256 hex
```
From teressa-copeland-homes/src/lib/signing/audit.ts:
```typescript
export async function logAuditEvent(opts: {
documentId: string;
eventType: 'signature_submitted' | 'pdf_hash_computed' | ...;
ipAddress?: string;
metadata?: Record<string, unknown>;
}): Promise<void>
```
From teressa-copeland-homes/src/lib/signing/token.ts:
```typescript
export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }>
```
From teressa-copeland-homes/src/lib/db/schema.ts:
```typescript
export const signingTokens = pgTable('signing_tokens', {
jti: text('jti').primaryKey(),
documentId: text('document_id').notNull(),
usedAt: timestamp('used_at'), // NULL = unused — MUST update atomically on first use
});
// documents table new columns (Plan 06-01):
// signedFilePath: text
// pdfHash: text
// signedAt: timestamp
```
From Plan 06-03: SigningPageClient.tsx already exists with:
- `onFieldClick(fieldId)` prop stub that opens modal
- Local `signedFields: Map<string, string>` state where key=fieldId, value=dataURL
- Token is available in the component (passed as prop from page.tsx)
- All pages rendered with react-pdf, field overlays positioned absolutely
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: SignatureModal — Draw/Type/Use Saved tabs with signature_pad</name>
<files>
teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx
teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx
</files>
<action>
**Create src/app/sign/[token]/_components/SignatureModal.tsx** — 'use client' modal with three tabs:
Locked decisions from CONTEXT.md:
- Tabs: Draw | Type | Use Saved (Use Saved only appears if localStorage has a saved signature)
- Draw: freehand signature_pad canvas
- Type: client types name, rendered in cursive font (Dancing Script — Claude's discretion)
- Clear/Redo button within each mode before confirming
- After confirming, field is marked signed
Implementation details:
```typescript
'use client';
import { useEffect, useRef, useState } from 'react';
import SignaturePad from 'signature_pad';
const SAVED_SIG_KEY = 'teressa_homes_saved_signature';
interface SignatureModalProps {
isOpen: boolean;
fieldId: string;
onConfirm: (fieldId: string, dataURL: string, save: boolean) => void;
onClose: () => void;
}
export function SignatureModal({ isOpen, fieldId, onConfirm, onClose }: SignatureModalProps) {
const [tab, setTab] = useState<'draw' | 'type' | 'saved'>('draw');
const [typedName, setTypedName] = useState('');
const [saveForLater, setSaveForLater] = useState(false);
const [savedSig, setSavedSig] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sigPadRef = useRef<SignaturePad | null>(null);
// Load saved signature from localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
setSavedSig(localStorage.getItem(SAVED_SIG_KEY));
}
}, [isOpen]);
// Initialize signature_pad with devicePixelRatio scaling (CRITICAL for mobile sharpness)
useEffect(() => {
if (!isOpen || tab !== 'draw' || !canvasRef.current) return;
const canvas = canvasRef.current;
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext('2d')?.scale(ratio, ratio);
sigPadRef.current = new SignaturePad(canvas, {
backgroundColor: 'rgba(0,0,0,0)',
penColor: '#1B2B4B',
});
return () => sigPadRef.current?.off();
}, [isOpen, tab]);
function renderTypedSignature(name: string): string {
// Offscreen canvas render of typed name in Dancing Script cursive font
const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 80;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, 400, 80);
ctx.font = "bold 44px 'Dancing Script', cursive";
ctx.fillStyle = '#1B2B4B';
ctx.textBaseline = 'middle';
ctx.fillText(name, 10, 40);
return canvas.toDataURL('image/png');
}
function handleConfirm() {
let dataURL: string | null = null;
if (tab === 'draw') {
if (!sigPadRef.current || sigPadRef.current.isEmpty()) return;
dataURL = sigPadRef.current.toDataURL('image/png');
} else if (tab === 'type') {
if (!typedName.trim()) return;
dataURL = renderTypedSignature(typedName.trim());
} else if (tab === 'saved') {
dataURL = savedSig;
}
if (!dataURL) return;
if (saveForLater) {
localStorage.setItem(SAVED_SIG_KEY, dataURL);
}
onConfirm(fieldId, dataURL, saveForLater);
}
if (!isOpen) return null;
const tabStyle = (active: boolean) => ({
padding: '8px 20px', cursor: 'pointer', border: 'none', background: 'none',
borderBottom: active ? '2px solid #C9A84C' : '2px solid transparent',
color: active ? '#1B2B4B' : '#666', fontWeight: active ? 'bold' : 'normal', fontSize: '15px',
});
return (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ backgroundColor: '#fff', borderRadius: '8px', padding: '24px', width: '480px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
<h2 style={{ color: '#1B2B4B', margin: 0, fontSize: '18px' }}>Add Signature</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', color: '#666' }}></button>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid #e0e0e0', marginBottom: '16px' }}>
<button style={tabStyle(tab === 'draw')} onClick={() => setTab('draw')}>Draw</button>
<button style={tabStyle(tab === 'type')} onClick={() => setTab('type')}>Type</button>
{savedSig && <button style={tabStyle(tab === 'saved')} onClick={() => setTab('saved')}>Use Saved</button>}
</div>
{/* Draw tab */}
{tab === 'draw' && (
<div>
<canvas
ref={canvasRef}
style={{ width: '100%', height: '140px', border: '1px solid #ddd', borderRadius: '4px', touchAction: 'none', display: 'block' }}
className="touch-none"
/>
<button onClick={() => sigPadRef.current?.clear()} style={{ marginTop: '8px', fontSize: '13px', color: '#888', background: 'none', border: 'none', cursor: 'pointer' }}>
Clear
</button>
</div>
)}
{/* Type tab */}
{tab === 'type' && (
<div>
{/* Load Dancing Script from Google Fonts */}
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&display=swap" rel="stylesheet" />
<input
type="text"
value={typedName}
onChange={(e) => setTypedName(e.target.value)}
placeholder="Type your full name"
style={{ width: '100%', padding: '10px', fontSize: '16px', border: '1px solid #ddd', borderRadius: '4px', boxSizing: 'border-box' }}
/>
{typedName && (
<div style={{ marginTop: '12px', padding: '16px', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#fafafa' }}>
<span style={{ fontFamily: "'Dancing Script', cursive", fontSize: '36px', color: '#1B2B4B' }}>{typedName}</span>
</div>
)}
</div>
)}
{/* Use Saved tab */}
{tab === 'saved' && savedSig && (
<div style={{ padding: '16px', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}>
<img src={savedSig} alt="Saved signature" style={{ maxWidth: '100%', maxHeight: '100px' }} />
</div>
)}
{/* Save for later checkbox (only on draw/type tabs) */}
{tab !== 'saved' && (
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '12px', fontSize: '14px', color: '#555', cursor: 'pointer' }}>
<input type="checkbox" checked={saveForLater} onChange={(e) => setSaveForLater(e.target.checked)} />
Save signature for other fields
</label>
)}
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '10px 20px', border: '1px solid #ddd', borderRadius: '4px', background: 'none', cursor: 'pointer', fontSize: '14px' }}>
Cancel
</button>
<button
onClick={handleConfirm}
style={{ padding: '10px 24px', backgroundColor: '#C9A84C', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', fontSize: '14px' }}
>
Apply Signature
</button>
</div>
</div>
</div>
);
}
```
**Update SigningPageClient.tsx** — wire the modal:
1. Import SignatureModal
2. Add state: `const [modalOpen, setModalOpen] = useState(false)` and `const [activeFieldId, setActiveFieldId] = useState<string | null>(null)`
3. Replace the no-op `onFieldClick` stub with: `setActiveFieldId(fieldId); setModalOpen(true)`
4. Add `onConfirm` handler: store `{ fieldId, dataURL }` in a `signedFields` Map state; if all fields signed, enable Submit
5. Render `<SignatureModal isOpen={modalOpen} fieldId={activeFieldId ?? ''} onConfirm={handleModalConfirm} onClose={() => setModalOpen(false)} />`
6. Update the field overlay: if `signedFields.has(field.id)`, show a small signature image preview inside the overlay instead of the pulsing outline
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "error|Error" | grep -v "^$" | head -10</automated>
</verify>
<done>SignatureModal.tsx exists; SigningPageClient.tsx imports and renders it; npm run build passes with no TypeScript errors</done>
</task>
<task type="auto">
<name>Task 2: POST /api/sign/[token] — atomic submission with PDF embedding + audit trail</name>
<files>
teressa-copeland-homes/src/app/api/sign/[token]/route.ts
</files>
<action>
Add a POST handler to the existing `src/app/api/sign/[token]/route.ts` file (which already has the GET handler from Plan 06-03).
The POST handler implements the signing submission with CRITICAL one-time enforcement:
Request body (JSON): `{ signatures: Array<{ fieldId: string; dataURL: string }> }`
Logic (order is critical):
1. Parse body: `const { signatures } = await req.json()`
2. Resolve token from params, call `verifySigningToken(token)` — on failure return 401
3. **ATOMIC ONE-TIME ENFORCEMENT** (the most critical step):
```sql
UPDATE signing_tokens SET used_at = NOW() WHERE jti = ? AND used_at IS NULL RETURNING jti
```
Use Drizzle: `db.update(signingTokens).set({ usedAt: new Date() }).where(and(eq(signingTokens.jti, payload.jti), isNull(signingTokens.usedAt))).returning()`
If 0 rows returned → return 409 `{ error: 'already-signed' }` — do NOT proceed to embed PDF
4. Log `signature_submitted` audit event (with IP from headers)
5. Fetch document with `signatureFields` and `preparedFilePath`
6. Guard: if `preparedFilePath` is null return 422 (should never happen, but defensive)
7. Build `signedFilePath`: same directory as preparedFilePath but with `_signed.pdf` suffix
```typescript
const signedFilePath = doc.preparedFilePath!.replace(/_prepared\.pdf$/, '_signed.pdf');
```
8. Merge client-supplied signature dataURLs with server-stored field coordinates:
```typescript
const signaturesWithCoords = (doc.signatureFields ?? []).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 };
});
```
9. Call `embedSignatureInPdf(doc.preparedFilePath!, signedFilePath, signaturesWithCoords)` — returns SHA-256 hash
10. Log `pdf_hash_computed` audit event with `metadata: { hash, signedFilePath }`
11. Update documents table: `status = 'Signed', signedAt = new Date(), signedFilePath, pdfHash = hash`
12. Fire-and-forget: call `sendAgentNotificationEmail` (catch and log errors but do NOT fail the response)
13. Return 200 JSON: `{ ok: true }` — client redirects to `/sign/${token}/confirmed`
IP/UA extraction:
```typescript
const hdrs = await headers();
const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
const ua = hdrs.get('user-agent') ?? 'unknown';
```
Import sendAgentNotificationEmail from signing-mailer.tsx for the agent notification.
NEVER trust coordinates from the client request body — always use `signatureFields` from the DB. Client only provides fieldId and dataURL. Coordinates come from server.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5</automated>
</verify>
<done>POST handler added to sign/[token]/route.ts; npm run build passes; grep confirms UPDATE...WHERE usedAt IS NULL pattern in route.ts; documents columns updated in the handler</done>
</task>
</tasks>
<verification>
- Build passes: `npm run build` exits 0
- Modal exists: `ls teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx`
- Atomic check present: `grep -n "isNull\|usedAt IS NULL\|usedAt.*null" teressa-copeland-homes/src/app/api/sign/*/route.ts`
- Hash logged: `grep -n "pdf_hash_computed\|pdfHash" teressa-copeland-homes/src/app/api/sign/*/route.ts`
- Coordinates from DB only: verify route does NOT read x/y/width/height from request body
</verification>
<success_criteria>
Submission flow complete when: modal opens on field click with Draw/Type/Use Saved tabs, signature_pad initializes with devicePixelRatio scaling and touch-none, POST route atomically claims the token (0 rows = 409), embeds all signatures at server-stored coordinates, stores pdfHash, logs all remaining audit events, and npm run build passes.
</success_criteria>
<output>
After completion, create `.planning/phases/06-signing-flow/06-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,305 @@
---
phase: 06-signing-flow
plan: "05"
type: execute
wave: 4
depends_on:
- "06-04"
files_modified:
- teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx
- teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts
- teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx
autonomous: true
requirements:
- SIGN-06
must_haves:
truths:
- "After POST /api/sign/[token] returns ok:true, the client is redirected to /sign/[token]/confirmed"
- "Confirmation page shows: success checkmark, 'You've signed [Document Name]', timestamp of signing, and a 'Download your copy' button"
- "The 'Download your copy' button downloads the signed PDF for the client via a short-lived download token (15-min TTL)"
- "Revisiting an already-used /sign/[token] shows the 'Already Signed' state with signed date and a 'Download your copy' link"
- "GET /api/sign/[token]/download validates a download JWT token (not the signing token) and streams the signedFilePath PDF"
- "npm run build passes cleanly"
artifacts:
- path: "teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx"
provides: "Post-signing confirmation page with signed date + download button"
- path: "teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts"
provides: "GET: validate download token, stream signedFilePath as PDF"
key_links:
- from: "confirmed/page.tsx"
to: "/api/sign/[token]/download?dt=[downloadToken]"
via: "download button href with short-lived token query param"
- from: "download/route.ts"
to: "documents.signedFilePath"
via: "reads file from uploads/ and streams as application/pdf — never from public directory"
- from: "SigningPageClient.tsx"
to: "/sign/[token]/confirmed"
via: "router.push after POST /api/sign/[token] returns ok:true"
---
<objective>
Build the post-signing confirmation screen and the client's ability to download a copy of their signed document via a short-lived token.
Purpose: SIGN-06 — client must see confirmation after signing. The confirmation page is the final touch in the signing ceremony UX (locked decision: success checkmark, document name, timestamp, download button).
Output: Confirmation page, already-signed download link, and /api/sign/[token]/download route for secure client PDF access.
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/06-signing-flow/06-CONTEXT.md
@.planning/phases/06-signing-flow/06-RESEARCH.md
@.planning/phases/06-signing-flow/06-04-SUMMARY.md
<interfaces>
<!-- From Plan 06-01 -->
From teressa-copeland-homes/src/lib/signing/token.ts:
```typescript
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 }>
```
Note: For the download token, use the same createSigningToken pattern but with a different purpose. The research recommends a second short-lived JWT with `purpose: 'download'` claim and 15-min TTL. Extend token.ts to add:
```typescript
export async function createDownloadToken(documentId: string): Promise<string>
// SignJWT with { documentId, purpose: 'download' }, setExpirationTime('15m'), no DB record needed
export async function verifyDownloadToken(token: string): Promise<{ documentId: string }>
// jwtVerify + check payload.purpose === 'download'
```
From teressa-copeland-homes/src/lib/db/schema.ts:
```typescript
// documents table — added in Plan 06-01:
// signedFilePath: text — absolute path to signed PDF
// signedAt: timestamp
// pdfHash: text
//
// signingTokens table:
// usedAt: timestamp — set when signing was completed
```
From Plan 06-04: POST /api/sign/[token] returns { ok: true } on success.
SigningPageClient.tsx submits via fetch POST and receives this response — needs router.push to /sign/[token]/confirmed after ok:true.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Download token utilities + download API route</name>
<files>
teressa-copeland-homes/src/lib/signing/token.ts
teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts
</files>
<action>
**Extend src/lib/signing/token.ts** — add two new exports for the client download token.
Append to the end of the existing token.ts file:
```typescript
// Short-lived download token for client copy download (15-min TTL, no DB record)
// purpose: 'download' claim distinguishes from signing tokens
export async function createDownloadToken(documentId: string): Promise<string> {
return await new SignJWT({ documentId, purpose: 'download' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(getSecret());
}
export async function verifyDownloadToken(token: string): Promise<{ documentId: string }> {
const { payload } = await jwtVerify(token, getSecret());
if (payload['purpose'] !== 'download') throw new Error('Not a download token');
return { documentId: payload['documentId'] as string };
}
```
**Create src/app/api/sign/[token]/download/route.ts** — GET handler:
The `[token]` in this path is the SIGNING token (used to identify the document via context). The actual download authorization uses a `dt` query parameter (the short-lived download token).
Logic:
1. Resolve signing token from params, verify it (allows expired JWT here — signed docs should remain downloadable briefly after link expiry)
- Actually: do NOT use the signing token for auth here. Instead, use ONLY the `dt` query param for authorization.
2. Get `dt` from URL search params: `const url = new URL(req.url); const dt = url.searchParams.get('dt')`
3. If no `dt`, return 401
4. Call `verifyDownloadToken(dt)` — if throws, return 401 "Download link expired or invalid"
5. Fetch document by `documentId` from the verified download token payload
6. Guard: if `doc.signedFilePath` is null, return 404 "Signed PDF not found"
7. Path traversal guard: ensure `doc.signedFilePath` starts with expected uploads/ prefix (same guard pattern used in Phase 4)
8. Read file: `const fileBuffer = await readFile(doc.signedFilePath)`
9. Return as streaming PDF response:
```typescript
return new Response(fileBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${doc.name.replace(/[^a-zA-Z0-9-_ ]/g, '')}_signed.pdf"`,
},
});
```
No agent auth required — download token is the authorization mechanism.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign/\[token\]/download" | head -5</automated>
</verify>
<done>createDownloadToken and verifyDownloadToken exported from token.ts; download/route.ts exists; build passes</done>
</task>
<task type="auto">
<name>Task 2: Confirmation page + redirect from signing client</name>
<files>
teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx
teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx
</files>
<action>
**Create src/app/sign/[token]/confirmed/page.tsx** — server component showing signing confirmation (locked UX decisions from CONTEXT.md):
- Success checkmark
- "You've signed [Document Name]"
- Timestamp of signing
- "Download your copy" button (generates a download token, passes as `dt` query param)
- Clean thank-you only — no agent contact info on the page (locked decision)
```typescript
import { verifySigningToken } from '@/lib/signing/token';
import { createDownloadToken } from '@/lib/signing/token';
import { db } from '@/lib/db';
import { signingTokens, documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
interface Props {
params: Promise<{ token: string }>;
}
export default async function ConfirmedPage({ params }: Props) {
const { token } = await params;
// Verify signing token to get documentId (allow expired — confirmation page visited after signing)
let documentId: string | null = null;
let jti: string | null = null;
try {
const payload = await verifySigningToken(token);
documentId = payload.documentId;
jti = payload.jti;
} catch {
// Token expired is OK here — signing may have happened right before expiry
// Try to look up by jti if token body is still parseable
}
if (!documentId) {
return <div style={{ padding: '40px', textAlign: 'center', fontFamily: 'Georgia, serif', color: '#1B2B4B' }}>Document not found.</div>;
}
const doc = await db.query.documents.findFirst({
where: eq(documents.id, documentId),
});
// Get signed timestamp from signingTokens.usedAt
let signedAt: Date | null = null;
if (jti) {
const tokenRow = await db.query.signingTokens.findFirst({ where: eq(signingTokens.jti, jti) });
signedAt = tokenRow?.usedAt ?? doc?.signedAt ?? null;
}
if (!doc || !doc.signedFilePath) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#FAF9F7', fontFamily: 'Georgia, serif' }}>
<div style={{ textAlign: 'center', padding: '40px' }}>
<h1 style={{ color: '#1B2B4B' }}>Document not yet available</h1>
<p style={{ color: '#555' }}>Please check back shortly.</p>
</div>
</div>
);
}
// Generate 15-minute download token for client copy
const downloadToken = await createDownloadToken(doc.id);
const downloadUrl = `/api/sign/${token}/download?dt=${downloadToken}`;
const formattedDate = signedAt
? signedAt.toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
: 'Just now';
return (
<div style={{ minHeight: '100vh', backgroundColor: '#FAF9F7', fontFamily: 'Georgia, serif', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', maxWidth: '480px', padding: '48px 32px' }}>
{/* Success checkmark */}
<div style={{ width: '72px', height: '72px', borderRadius: '50%', backgroundColor: '#1B2B4B', color: '#C9A84C', fontSize: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px' }}>
</div>
<h1 style={{ color: '#1B2B4B', fontSize: '26px', marginBottom: '8px' }}>
You&apos;ve signed
</h1>
<p style={{ color: '#1B2B4B', fontSize: '20px', fontWeight: 'bold', marginBottom: '8px' }}>
{doc.name}
</p>
<p style={{ color: '#888', fontSize: '14px', marginBottom: '32px' }}>
Signed on {formattedDate}
</p>
<a
href={downloadUrl}
style={{
display: 'inline-block',
backgroundColor: '#C9A84C',
color: '#fff',
padding: '12px 28px',
borderRadius: '4px',
textDecoration: 'none',
fontWeight: 'bold',
fontSize: '15px',
}}
>
Download your copy
</a>
<p style={{ color: '#aaa', fontSize: '12px', marginTop: '16px' }}>
Download link valid for 15 minutes.
</p>
</div>
</div>
);
}
```
**Update SigningPageClient.tsx** — add navigation to confirmed page after successful submission.
Find the `handleSubmit` function (or where the POST response is handled). After receiving `{ ok: true }`:
```typescript
import { useRouter } from 'next/navigation';
// ...
const router = useRouter();
// After POST returns ok:true:
router.push(`/sign/${token}/confirmed`);
```
The `token` prop is already passed from page.tsx to SigningPageClient. Use it for the redirect URL.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "confirmed|error|Error" | grep -v "^$" | head -10</automated>
</verify>
<done>confirmed/page.tsx exists; SigningPageClient.tsx uses router.push to confirmed after successful submit; download route streams PDF; npm run build passes</done>
</task>
</tasks>
<verification>
- Build passes: `npm run build` exits 0
- Confirmation page: `ls teressa-copeland-homes/src/app/sign/*/confirmed/page.tsx`
- Download route: `ls teressa-copeland-homes/src/app/api/sign/*/download/route.ts`
- Download token functions: `grep -n "createDownloadToken\|verifyDownloadToken" teressa-copeland-homes/src/lib/signing/token.ts`
- Client redirect: `grep -n "router.push\|confirmed" teressa-copeland-homes/src/app/sign/*/\_components/SigningPageClient.tsx`
</verification>
<success_criteria>
Post-signing flow complete when: confirmed page renders success checkmark + document name + signed timestamp + download button, download route streams the signedFilePath PDF using a 15-min token, SigningPageClient navigates to confirmed after successful POST, and npm run build passes.
</success_criteria>
<output>
After completion, create `.planning/phases/06-signing-flow/06-05-SUMMARY.md`
</output>

View File

@@ -0,0 +1,160 @@
---
phase: 06-signing-flow
plan: "06"
type: execute
wave: 5
depends_on:
- "06-05"
files_modified: []
autonomous: false
requirements:
- LEGAL-04
must_haves:
truths:
- "SPF record exists for teressacopelandhomes.com with no duplicate (only one v=spf1 record)"
- "DKIM record exists for teressacopelandhomes.com (TXT record at [selector]._domainkey subdomain)"
- "DMARC record exists at _dmarc.teressacopelandhomes.com with at minimum p=none"
- "MXToolbox SPF check shows green/pass"
- "MXToolbox DKIM check shows green/pass"
- "MXToolbox DMARC check shows green/pass"
- "A test signing email sent to a real email address is received without spam filtering"
artifacts: []
key_links: []
user_setup:
- service: dns-spf-dkim-dmarc
why: "LEGAL-04 — DNS email authentication must be configured before any signing link is sent to a real client"
env_vars: []
dashboard_config:
- task: "Check existing SPF record"
location: "Run: dig TXT teressacopelandhomes.com | grep v=spf1 — if a record exists, MERGE your SMTP provider include into it (do not add a second SPF record — RFC 7208 forbids multiple v=spf1 records)"
- task: "Add SPF TXT record"
location: "DNS provider (Namecheap, Google Domains, etc.) — add TXT @ record: v=spf1 include:[smtp-provider] ~all"
- task: "Generate and add DKIM record"
location: "Your SMTP provider dashboard (Google Workspace: Admin > Apps > Gmail > Authenticate Email; Namecheap: Email > DKIM; Zoho: Mail > Domains > DKIM)"
- task: "Add DMARC TXT record"
location: "DNS provider — add TXT _dmarc record: v=DMARC1; p=none; rua=mailto:teressa@teressacopelandhomes.com"
---
<objective>
Verify that DNS email authentication (SPF, DKIM, DMARC) is correctly configured for teressacopelandhomes.com before any signing link is sent to a real client.
Purpose: LEGAL-04 — this is a non-negotiable compliance gate. Without SPF/DKIM/DMARC, signing emails will be spam-filtered or rejected, and the audit trail will be incomplete.
Output: All three DNS records verified as passing in MXToolbox + a real test email received successfully.
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/06-signing-flow/06-CONTEXT.md
@.planning/phases/06-signing-flow/06-RESEARCH.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Automated DNS verification check</name>
<files></files>
<action>
Run DNS record checks using dig to show the current state before the human checkpoint:
```bash
# Check existing SPF (CRITICAL: only one v=spf1 record allowed per RFC 7208)
dig TXT teressacopelandhomes.com | grep -i "v=spf"
# Check for DKIM records (selector depends on SMTP provider — common selectors: google, default, mail, zoho)
dig TXT google._domainkey.teressacopelandhomes.com
dig TXT default._domainkey.teressacopelandhomes.com
dig TXT mail._domainkey.teressacopelandhomes.com
# Check DMARC
dig TXT _dmarc.teressacopelandhomes.com
```
Report the current state of each record. If any is missing, flag it clearly.
Also run a test SMTP connection to confirm the configured SMTP credentials work:
```bash
# Quick SMTP auth test using Node.js (from teressa-copeland-homes/)
cd /Users/ccopeland/temp/red/teressa-copeland-homes && node -e "
const nodemailer = require('nodemailer');
require('dotenv').config({ path: '.env.local' });
const t = nodemailer.createTransport({
host: process.env.CONTACT_SMTP_HOST,
port: Number(process.env.CONTACT_SMTP_PORT || 587),
auth: { user: process.env.CONTACT_EMAIL_USER, pass: process.env.CONTACT_EMAIL_PASS }
});
t.verify().then(() => console.log('SMTP: OK')).catch(e => console.error('SMTP error:', e.message));
"
```
Output a summary of: which DNS records are present, which are missing, and whether SMTP auth succeeds.
</action>
<verify>
<automated>dig TXT teressacopelandhomes.com | grep -E "v=spf|ANSWER" && dig TXT _dmarc.teressacopelandhomes.com | grep -E "v=DMARC|ANSWER"</automated>
</verify>
<done>DNS check output produced showing current state of SPF, DKIM, and DMARC records for teressacopelandhomes.com</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Human DNS configuration + MXToolbox verification gate</name>
<files></files>
<action>
Human task — cannot be automated. Configure SPF/DKIM/DMARC DNS records for teressacopelandhomes.com at your DNS provider and SMTP provider dashboard. See how-to-verify steps below.
</action>
<verify>
<automated>MISSING — human must verify using MXToolbox at https://mxtoolbox.com/spf.aspx, https://mxtoolbox.com/dkim.aspx, https://mxtoolbox.com/dmarc.aspx</automated>
</verify>
<done>All three MXToolbox checks show green/pass; test email received in inbox (not spam)</done>
<what-built>
Automated DNS checks above show the current state of SPF, DKIM, and DMARC records for teressacopelandhomes.com.
The signing flow code is complete (plans 01-05). This checkpoint verifies DNS is configured before any real client signing link is sent.
</what-built>
<how-to-verify>
STEP 1: Check existing SPF record to avoid duplicates
- Run: dig TXT teressacopelandhomes.com | grep "v=spf"
- If a record exists: MERGE your SMTP provider's include into it (do NOT add a second v=spf1 record)
- If no record exists: Add: TXT @ "v=spf1 include:[your-smtp-provider-include] ~all"
STEP 2: Add DKIM key (get from your SMTP provider dashboard)
- Google Workspace: Admin console > Apps > Google Workspace > Gmail > Authenticate email
- Namecheap Email / Zoho Mail: Domain settings > DKIM
- Add the TXT record they provide at [selector]._domainkey.teressacopelandhomes.com
STEP 3: Add DMARC (monitoring mode — start with p=none)
- Add TXT _dmarc record: "v=DMARC1; p=none; rua=mailto:teressa@teressacopelandhomes.com"
STEP 4: Wait for DNS propagation (5 min to 1 hour for most providers)
STEP 5: Verify all three pass at:
- SPF: https://mxtoolbox.com/spf.aspx (enter teressacopelandhomes.com)
- DKIM: https://mxtoolbox.com/dkim.aspx (enter domain + selector)
- DMARC: https://mxtoolbox.com/dmarc.aspx (enter teressacopelandhomes.com)
All three must show green/pass before sending any real client signing link.
STEP 6: Send a test signing email from the app to your own email address and confirm it is received (not in spam).
</how-to-verify>
<resume-signal>
Type "dns verified" once all three MXToolbox checks show green/pass and a test email is received successfully.
Or describe any specific issues encountered (e.g., "SPF already exists — merged", "DKIM pending propagation").
</resume-signal>
</task>
</tasks>
<verification>
All three MXToolbox checks pass green (SPF, DKIM, DMARC). Test signing email received in inbox (not spam). DNS propagation complete.
</verification>
<success_criteria>
LEGAL-04 satisfied when: SPF/DKIM/DMARC all show green in MXToolbox, a real test email is received without spam filtering, and Teressa confirms she has reviewed and approved the email template. After this checkpoint, signing links may be sent to real clients.
</success_criteria>
<output>
After completion, create `.planning/phases/06-signing-flow/06-06-SUMMARY.md`
</output>