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

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
06-01
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
true
SIGN-02
SIGN-03
LEGAL-01
truths artifacts key_links
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
path provides
teressa-copeland-homes/src/app/sign/[token]/page.tsx Server component — validates token, renders signing/error state
path provides
teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx PDF viewer + field overlays + progress bar — client component
path provides
teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx Sticky progress bar with jump-to-next field navigation
path provides
teressa-copeland-homes/src/app/api/sign/[token]/route.ts GET: validate token, return doc data, log audit events
from to via
sign/[token]/page.tsx verifySigningToken() + signingTokens DB lookup server component validates before rendering any UI
from to via
SigningPageClient.tsx react-pdf Document + Page components renders prepared PDF all pages scrollable
from to via
SigningPageClient.tsx signatureFields coordinates 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.

<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

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

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

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:

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

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