docs(06-signing-flow): create phase plan
This commit is contained in:
353
.planning/phases/06-signing-flow/06-03-PLAN.md
Normal file
353
.planning/phases/06-signing-flow/06-03-PLAN.md
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 06-01 -->
|
||||
|
||||
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<string, unknown>;
|
||||
}): Promise<void>
|
||||
```
|
||||
|
||||
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<SignatureFieldData[]>()
|
||||
// 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)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: GET /api/sign/[token] route — validate token + audit logging</name>
|
||||
<files>
|
||||
teressa-copeland-homes/src/app/api/sign/[token]/route.ts
|
||||
</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign/\[token\]" | head -5</automated>
|
||||
</verify>
|
||||
<done>GET /api/sign/[token] exists and builds; returns appropriate JSON for expired/used/pending states</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Signing page server component + client PDF viewer + progress bar</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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 <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):
|
||||
|
||||
```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 (
|
||||
<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:
|
||||
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 `<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: absolute` inside 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's `onRenderSuccess` callback 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 with `Content-Type: application/pdf`
|
||||
- No agent auth required — authenticated by the signing token
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign" | head -10</automated>
|
||||
</verify>
|
||||
<done>/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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- Build passes: `npm run build` exits 0
|
||||
- Sign page files exist: `find teressa-copeland-homes/src/app/sign -name "*.tsx" | sort`
|
||||
- Sign API routes exist: `find teressa-copeland-homes/src/app/api/sign -name "route.ts" | sort`
|
||||
- Error states guard canvas: grep confirms `ErrorPage` rendered before any SigningPageClient on invalid tokens
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-signing-flow/06-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user