--- 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" --- 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. @/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 @.planning/phases/06-signing-flow/06-03-SUMMARY.md 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 // 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; }): Promise ``` 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` 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 Task 1: SignatureModal — Draw/Type/Use Saved tabs with signature_pad teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx **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(null); const canvasRef = useRef(null); const sigPadRef = useRef(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 (

Add Signature

{/* Tabs */}
{savedSig && }
{/* Draw tab */} {tab === 'draw' && (
)} {/* Type tab */} {tab === 'type' && (
{/* Load Dancing Script from Google Fonts */} 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 && (
{typedName}
)}
)} {/* Use Saved tab */} {tab === 'saved' && savedSig && (
Saved signature
)} {/* Save for later checkbox (only on draw/type tabs) */} {tab !== 'saved' && ( )}
); } ``` **Update SigningPageClient.tsx** — wire the modal: 1. Import SignatureModal 2. Add state: `const [modalOpen, setModalOpen] = useState(false)` and `const [activeFieldId, setActiveFieldId] = useState(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 ` 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
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "error|Error" | grep -v "^$" | head -10 SignatureModal.tsx exists; SigningPageClient.tsx imports and renders it; npm run build passes with no TypeScript errors
Task 2: POST /api/sign/[token] — atomic submission with PDF embedding + audit trail teressa-copeland-homes/src/app/api/sign/[token]/route.ts 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. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5 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
- 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 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. After completion, create `.planning/phases/06-signing-flow/06-04-SUMMARY.md`