docs(06-signing-flow): create phase plan
This commit is contained in:
400
.planning/phases/06-signing-flow/06-04-PLAN.md
Normal file
400
.planning/phases/06-signing-flow/06-04-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user