Files
red/.planning/phases/06-signing-flow/06-04-PLAN.md
2026-03-20 11:18:47 -06:00

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-signing-flow 04 execute 3
06-02
06-03
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
true
SIGN-04
SIGN-05
LEGAL-01
LEGAL-02
truths artifacts key_links
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
path provides exports
teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx Draw/Type/Use Saved modal with signature_pad
SignatureModal
path provides
teressa-copeland-homes/src/app/api/sign/[token]/route.ts POST: atomic usedAt, embedSignatureInPdf, hash, audit events, redirect
from to via
SignatureModal.tsx signature_pad library useEffect mounts SignaturePad on canvas ref with devicePixelRatio scaling
from to via
POST /api/sign/[token] signingTokens table UPDATE SET usedAt WHERE jti=? AND usedAt IS NULL RETURNING — atomic one-time enforcement
from to via
POST /api/sign/[token] embedSignatureInPdf() in embed-signature.ts calls with preparedFilePath + all signature dataURLs + coordinates
from to via
POST /api/sign/[token] documents table pdfHash + signedAt columns 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.

<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_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

From teressa-copeland-homes/src/lib/signing/embed-signature.ts:

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:

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:

export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }>

From teressa-copeland-homes/src/lib/db/schema.ts:

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
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:

'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 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):
    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
    const signedFilePath = doc.preparedFilePath!.replace(/_prepared\.pdf$/, '_signed.pdf');
    
  8. Merge client-supplied signature dataURLs with server-stored field coordinates:
    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:

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

<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>

After completion, create `.planning/phases/06-signing-flow/06-04-SUMMARY.md`