16 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 | 03 | execute | 2 |
|
|
true |
|
|
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.
<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.mdFrom teressa-copeland-homes/src/lib/signing/token.ts:
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:
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<string, unknown>;
}): Promise<void>
From teressa-copeland-homes/src/lib/db/schema.ts (relevant):
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<SignatureFieldData[]>()
// documents.preparedFilePath: text
// documents.signedAt: timestamp
Existing PdfViewer.tsx (Phase 4/5 — portal-only):
- Renders with react-pdf
Document+Pagecomponents - 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)
Logic:
- Resolve token from params:
const { token } = await params - Verify JWT: call
verifySigningToken(token)— if it throws (expired/invalid), return appropriate JSON - Look up
jtiin signingTokens table — ifusedAtis NOT NULL, return{ status: 'used', signedAt: row.usedAt } - Fetch document with
signatureFields,preparedFilePath,namecolumns - Log
link_openedevent with IP and user-agent extracted from request headers (x-forwarded-for, user-agent) - Log
document_viewedevent (client opened the signing page — both events fire together on GET) - 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:
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
src/app/sign/[token]/page.tsx — server component, validates token before rendering ANY UI (CRITICAL: no canvas flash on invalid tokens):
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 <ErrorPage type="expired" />;
}
if (!payload) {
return <ErrorPage type="invalid" />;
}
// Check one-time use
const tokenRow = await db.query.signingTokens.findFirst({
where: eq(signingTokens.jti, payload.jti),
});
if (!tokenRow) return <ErrorPage type="invalid" />;
if (tokenRow.usedAt !== null) {
return <ErrorPage type="used" signedAt={tokenRow.usedAt} />;
}
const doc = await db.query.documents.findFirst({
where: eq(documents.id, payload.documentId),
});
if (!doc || !doc.preparedFilePath) return <ErrorPage type="invalid" />;
return (
<SigningPageClient
token={token}
documentName={doc.name}
signatureFields={doc.signatureFields ?? []}
/>
);
}
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 (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#FAF9F7', fontFamily: 'Georgia, serif' }}>
<div style={{ textAlign: 'center', maxWidth: '420px', padding: '40px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{type === 'used' ? '✓' : '⚠'}</div>
<h1 style={{ color: '#1B2B4B', fontSize: '24px', marginBottom: '12px' }}>{title}</h1>
<p style={{ color: '#555', fontSize: '16px', lineHeight: '1.6' }}>{body}</p>
</div>
</div>
);
}
src/app/sign/[token]/_components/SigningProgressBar.tsx — sticky progress bar (locked decision: sticky at bottom, "X of Y signatures complete" + jump-to-next):
'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 (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50,
backgroundColor: '#1B2B4B', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 24px', boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
}}>
<span style={{ fontSize: '15px' }}>
{signed} of {total} signature{total !== 1 ? 's' : ''} complete
</span>
<div style={{ display: 'flex', gap: '12px' }}>
{!allSigned && (
<button
onClick={onJumpToNext}
style={{ backgroundColor: 'transparent', border: '1px solid #C9A84C', color: '#C9A84C', padding: '8px 18px', borderRadius: '4px', cursor: 'pointer', fontSize: '14px' }}
>
Jump to Next
</button>
)}
<button
onClick={onSubmit}
disabled={!allSigned || submitting}
style={{
backgroundColor: allSigned ? '#C9A84C' : '#555',
color: '#fff', border: 'none', padding: '8px 22px', borderRadius: '4px',
cursor: allSigned ? 'pointer' : 'not-allowed', fontSize: '14px', fontWeight: 'bold',
opacity: submitting ? 0.7 : 1,
}}
>
{submitting ? 'Submitting...' : 'Submit Signature'}
</button>
</div>
</div>
);
}
src/app/sign/[token]/_components/SigningPageClient.tsx — main client component.
This is a 'use client' component that:
- Shows branded page header: "Teressa Copeland Homes" + document title + "Please review and sign the document below."
- 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
- Renders absolutely-positioned overlay divs for each signature field with CSS
animation: pulse-border 2s infinite— glowing blue outline (locked decision) - Tracks which fields have been signed in local state
- Renders the sticky SigningProgressBar
- 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 - "Jump to Next" scrolls to the next unsigned field using
document.getElementById('field-'+fieldId)?.scrollIntoView - Exports
signaturesRefstate (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]/pdfroute 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-distworker — reuse the sameGlobalWorkerOptions.workerSrc = new URL(...)pattern from the existing PdfViewerWrapper.tsx - Add
<style>tag with keyframes for the pulsing field animation:@keyframes pulse-border { 0%, 100% { box-shadow: 0 0 0 2px #3b82f6, 0 0 8px 2px rgba(59,130,246,0.4); } 50% { box-shadow: 0 0 0 3px #3b82f6, 0 0 16px 4px rgba(59,130,246,0.6); } } - Field overlay positioning: each field div is
position: absoluteinside a relative container that wraps each Page. The PDF coordinates from signatureFields are in PDF user space (bottom-left origin). To convert to screen position for the overlay: the Y position from top = pageHeightPx - (field.y / pageHeightPts * pageHeightPx) - (field.height / pageHeightPts * pageHeightPx). Use the rendered page height. For simplicity, render all pages at a fixed 800px width; actual page height is computed from the Page'sonRenderSuccesscallback which provides the rendered dimensions.
The modal (SignatureModal) and submission POST are added in Plan 04. For now, the onFieldClick prop can be a no-op stub so the page compiles and renders.
Also create the PDF-serving route: src/app/api/sign/[token]/pdf/route.ts:
- GET handler: validate signing token JWT (no usedAt check — just that it's a valid JWT for this doc), fetch doc.preparedFilePath from DB, read file with
readFile, return as Response withContent-Type: application/pdf - No agent auth required — authenticated by the signing token cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign" | head -10 /sign/[token]/page.tsx and all _components files exist and build cleanly; visiting /sign/[invalid-token] renders error page (not 500); build shows /sign/[token] as a dynamic route
<success_criteria> Signing page complete when: server component validates token before any UI renders, three error states (expired/used/invalid) show static pages with no canvas, valid token shows branded page header + PDF viewer + pulsing blue field overlays + sticky progress bar, and npm run build passes. </success_criteria>
After completion, create `.planning/phases/06-signing-flow/06-03-SUMMARY.md`