From 6cf228c779293b5fac19a5b9826128fe481526d8 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 20 Mar 2026 11:18:47 -0600 Subject: [PATCH] docs(06-signing-flow): create phase plan --- .planning/ROADMAP.md | 12 +- .../phases/06-signing-flow/06-01-PLAN.md | 345 +++++++++++++++ .../phases/06-signing-flow/06-02-PLAN.md | 384 +++++++++++++++++ .../phases/06-signing-flow/06-03-PLAN.md | 353 ++++++++++++++++ .../phases/06-signing-flow/06-04-PLAN.md | 400 ++++++++++++++++++ .../phases/06-signing-flow/06-05-PLAN.md | 305 +++++++++++++ .../phases/06-signing-flow/06-06-PLAN.md | 160 +++++++ 7 files changed, 1956 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/06-signing-flow/06-01-PLAN.md create mode 100644 .planning/phases/06-signing-flow/06-02-PLAN.md create mode 100644 .planning/phases/06-signing-flow/06-03-PLAN.md create mode 100644 .planning/phases/06-signing-flow/06-04-PLAN.md create mode 100644 .planning/phases/06-signing-flow/06-05-PLAN.md create mode 100644 .planning/phases/06-signing-flow/06-06-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7dc0f0f..fce768c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -119,9 +119,15 @@ Plans: 6. The system logs all 6 audit trail events server-side (document prepared, email sent, link opened with IP/UA, document viewed, signature submitted, final PDF hash computed) — all timestamps are server-side 7. A SHA-256 hash of the final signed PDF is computed and stored immediately after signature embedding 8. DNS (SPF/DKIM/DMARC) is configured for teressacopelandhomes.com before any signing link is sent to a real client -**Plans**: TBD +**Plans**: 6 plans -Plans: none yet +Plans: +- [ ] 06-01-PLAN.md — Schema migration 0005 (signingTokens + auditEvents tables + 3 documents columns), signing utility library (token.ts, audit.ts, embed-signature.ts), npm install signature_pad + @react-email +- [ ] 06-02-PLAN.md — Branded signing request email (React Email), signing-mailer utilities, POST /api/documents/[id]/send route, document_prepared audit logging in prepare route +- [ ] 06-03-PLAN.md — Public /sign/[token] page (3 states: signing/already-signed/expired), react-pdf viewer with pulsing blue field overlays, sticky progress bar, GET /api/sign/[token] data route +- [ ] 06-04-PLAN.md — SignatureModal (Draw/Type/Use Saved tabs, signature_pad with devicePixelRatio scaling), POST /api/sign/[token] with atomic usedAt enforcement, PDF embedding, SHA-256 hash +- [ ] 06-05-PLAN.md — Confirmation page (/sign/[token]/confirmed), 15-min client download token, GET /api/sign/[token]/download route +- [ ] 06-06-PLAN.md — DNS (SPF/DKIM/DMARC) verification checkpoint (LEGAL-04 gate) ### Phase 7: Audit Trail and Download **Goal**: Agent can download any signed PDF securely, and signed documents are never accessible via guessable public URLs @@ -147,5 +153,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 | 3. Agent Portal Shell | 4/4 | Complete | 2026-03-19 | | 4. PDF Ingest | 4/4 | Complete | 2026-03-20 | | 5. PDF Fill and Field Mapping | 3/4 | In Progress| | -| 6. Signing Flow | 0/? | Not started | - | +| 6. Signing Flow | 0/6 | Not started | - | | 7. Audit Trail and Download | 0/? | Not started | - | diff --git a/.planning/phases/06-signing-flow/06-01-PLAN.md b/.planning/phases/06-signing-flow/06-01-PLAN.md new file mode 100644 index 0000000..51a0029 --- /dev/null +++ b/.planning/phases/06-signing-flow/06-01-PLAN.md @@ -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" +--- + + +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. + + + +@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md +@/Users/ccopeland/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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() +// preparedFilePath: text — absolute path to the prepared PDF +// assignedClientId: text +``` + +Most recent migration file is 0004_military_maximus.sql — next migration must be 0005_*. + + + + + + + Task 1: Install packages + extend schema + generate migration + + teressa-copeland-homes/package.json + teressa-copeland-homes/src/lib/db/schema.ts + teressa-copeland-homes/drizzle/0005_signing_flow.sql + + +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>(), + 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. + + + 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" + + Both signing_tokens and audit_events tables appear in \dt output; npm run build compiles with no TypeScript errors for schema.ts + + + + Task 2: Create signing utility library (token + audit + embed) + + 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 + + +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; +}): Promise { + 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 { // 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 { + 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`) + + + cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5 + + All three utility files exist; npm run build passes with no TypeScript errors; SIGNING_JWT_SECRET placeholder added to .env.local + + + + + +- 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/ + + + +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. + + + +After completion, create `.planning/phases/06-signing-flow/06-01-SUMMARY.md` + diff --git a/.planning/phases/06-signing-flow/06-02-PLAN.md b/.planning/phases/06-signing-flow/06-02-PLAN.md new file mode 100644 index 0000000..e813296 --- /dev/null +++ b/.planning/phases/06-signing-flow/06-02-PLAN.md @@ -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 '" + - "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" +--- + + +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. + + + +@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md +@/Users/ccopeland/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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; +}): Promise +``` + +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. + + + + + + + Task 1: Branded signing request email + mailer + + teressa-copeland-homes/src/emails/SigningRequestEmail.tsx + teressa-copeland-homes/src/lib/signing/signing-mailer.tsx + + +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 ( + + + Please review and sign: {documentName} + + + + Teressa Copeland Homes + +
+ {clientName && ( + Hello {clientName}, + )} + + You have a document ready for your review and signature: + + + {documentName} + + + No account needed — just click the button below. + + + + This link expires on {expiryDate}. If you did not expect this document, you can safely ignore this email. + +
+ + Teressa Copeland Homes · Utah Licensed Real Estate Agent + +
+ + + ); +} +``` + +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 { + 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" ', + to: opts.to, + subject: `Please sign: ${opts.documentName}`, + html, + }); +} + +export async function sendAgentNotificationEmail(opts: { + clientName: string; + documentName: string; + signedAt: Date; +}): Promise { + 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" ', + to: 'teressa@teressacopelandhomes.com', + subject: `Signed: ${opts.documentName}`, + text: `${opts.clientName} has signed "${opts.documentName}" on ${formattedTime}.`, + }); +} +``` +
+ + cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5 + + SigningRequestEmail.tsx and signing-mailer.tsx exist and compile; npm run build passes +
+ + + Task 2: Send API route + document_prepared audit logging + + teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts + teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts + + +**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. + + + cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "error|Error|✓" | tail -10 + + 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 + + +
+ + +- 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` + + + +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. + + + +After completion, create `.planning/phases/06-signing-flow/06-02-SUMMARY.md` + diff --git a/.planning/phases/06-signing-flow/06-03-PLAN.md b/.planning/phases/06-signing-flow/06-03-PLAN.md new file mode 100644 index 0000000..81868f4 --- /dev/null +++ b/.planning/phases/06-signing-flow/06-03-PLAN.md @@ -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" +--- + + +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. + + + +@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md +@/Users/ccopeland/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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; +}): Promise +``` + +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() +// 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) + + + + + + + Task 1: GET /api/sign/[token] route — validate token + audit logging + + teressa-copeland-homes/src/app/api/sign/[token]/route.ts + + +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. + + + cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign/\[token\]" | head -5 + + GET /api/sign/[token] exists and builds; returns appropriate JSON for expired/used/pending states + + + + Task 2: Signing page server component + client PDF viewer + progress bar + + 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 + + +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 ; + } + + if (!payload) { + return ; + } + + // Check one-time use + const tokenRow = await db.query.signingTokens.findFirst({ + where: eq(signingTokens.jti, payload.jti), + }); + + if (!tokenRow) return ; + + if (tokenRow.usedAt !== null) { + return ; + } + + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, payload.documentId), + }); + + if (!doc || !doc.preparedFilePath) return ; + + return ( + + ); +} + +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 ( +
+
+
{type === 'used' ? '✓' : '⚠'}
+

{title}

+

{body}

+
+
+ ); +} +``` + +**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 ( +
+ + {signed} of {total} signature{total !== 1 ? 's' : ''} complete + +
+ {!allSigned && ( + + )} + +
+
+ ); +} +``` + +**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 `