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