Files
red/.planning/phases/11-agent-saved-signature-and-signing-workflow/11-01-PLAN.md

17 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
11-agent-saved-signature-and-signing-workflow 01 execute 1
teressa-copeland-homes/src/lib/db/schema.ts
teressa-copeland-homes/drizzle/0008_agent_signature.sql
teressa-copeland-homes/src/app/api/agent/signature/route.ts
teressa-copeland-homes/src/app/portal/(protected)/profile/page.tsx
teressa-copeland-homes/src/app/portal/_components/AgentSignaturePanel.tsx
teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
true
AGENT-01
AGENT-02
AGENT-03
truths artifacts key_links
Agent can navigate to /portal/profile via the nav
Agent can draw a signature on the canvas and save it
A thumbnail of the saved signature appears after saving
Agent can click 'Update Signature' to redraw and replace their saved signature
Agent signature palette token appears in the FieldPlacer (red 'Agent Signature' token)
Placing an agent-signature field saves it in signatureFields with type 'agent-signature'
path provides contains
teressa-copeland-homes/src/lib/db/schema.ts agentSignatureData TEXT column on users table agentSignatureData
path provides exports
teressa-copeland-homes/src/app/api/agent/signature/route.ts GET/PUT endpoints for reading and writing agent signature
GET
PUT
path provides
teressa-copeland-homes/src/app/portal/_components/AgentSignaturePanel.tsx Client component with signature_pad canvas, save/update/thumbnail flow
path provides
teressa-copeland-homes/src/app/portal/(protected)/profile/page.tsx Server component fetching agentSignatureData and rendering AgentSignaturePanel
from to via pattern
teressa-copeland-homes/src/app/portal/(protected)/profile/page.tsx teressa-copeland-homes/src/app/portal/_components/AgentSignaturePanel.tsx initialData prop (string | null) initialData.*agentSignatureData
from to via pattern
teressa-copeland-homes/src/app/portal/_components/AgentSignaturePanel.tsx /api/agent/signature fetch PUT with { dataURL } fetch.*api/agent/signature
from to via pattern
teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx /portal/profile navLinks entry portal/profile
Establish the agent signature storage and UI layer: DB migration adds `agentSignatureData TEXT` to users, GET/PUT API routes read/write the base64 PNG dataURL, a new `/portal/profile` page hosts the `AgentSignaturePanel` draw-and-save canvas component, the portal nav gains a Profile link, and the FieldPlacer palette gets the red "Agent Signature" token.

Purpose: Once the agent has saved their signature and the palette token exists, Plan 02 can wire up the preparePdf() embedding without any further schema or UI work. Output: Profile page with working draw/save/update signature flow; agent-signature token in field palette; zero new npm packages.

<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/STATE.md @.planning/phases/10-expanded-field-types-end-to-end/10-03-SUMMARY.md

From src/lib/db/schema.ts (current users table — agentSignatureData NOT YET present):

export const users = pgTable("users", {
  id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
  email: text("email").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  // Phase 11 adds: agentSignatureData: text("agent_signature_data"),
});

From src/lib/db/schema.ts (SignatureFieldType — agent-signature already valid):

export type SignatureFieldType =
  | 'client-signature'
  | 'initials'
  | 'text'
  | 'checkbox'
  | 'date'
  | 'agent-signature';

From FieldPlacer.tsx (PALETTE_TOKENS — agent-signature NOT YET present, validTypes already includes it):

// Current PALETTE_TOKENS (5 entries — Phase 11 adds the 6th):
const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [
  { id: 'client-signature', label: 'Signature',  color: '#2563eb' },
  { id: 'initials',         label: 'Initials',   color: '#7c3aed' },
  { id: 'checkbox',         label: 'Checkbox',   color: '#059669' },
  { id: 'date',             label: 'Date',       color: '#d97706' },
  { id: 'text',             label: 'Text',       color: '#64748b' },
  // ADD: { id: 'agent-signature', label: 'Agent Signature', color: '#dc2626' },
];

// validTypes already has 'agent-signature' at line 258:
const validTypes = new Set<string>(['client-signature','initials','text','checkbox','date','agent-signature']);

From src/app/sign/[token]/_components/SignatureModal.tsx (DPR canvas init — confirmed working):

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]);

From src/lib/auth.config.ts (session.user.id is set):

// callbacks.jwt: if (user) token.id = user.id;
// callbacks.session: session.user.id = token.id as string;
// Usage in any protected API route:
const session = await auth();
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });

Migration pattern from drizzle/0007_equal_nekra.sql:

-- Pattern for nullable TEXT column:
ALTER TABLE "clients" ADD COLUMN "property_address" text;
-- Phase 11 equivalent:
ALTER TABLE "users" ADD COLUMN "agent_signature_data" text;

From src/app/portal/_components/PortalNav.tsx (navLinks pattern):

// Current navLinks:
const navLinks = [
  { href: '/portal/dashboard', label: 'Dashboard' },
  { href: '/portal/clients', label: 'Clients' },
  // Phase 11 adds: { href: '/portal/profile', label: 'Profile' }
];
Task 1: DB migration and API routes for agent signature storage teressa-copeland-homes/src/lib/db/schema.ts teressa-copeland-homes/drizzle/0008_agent_signature.sql teressa-copeland-homes/src/app/api/agent/signature/route.ts Three changes in sequence:

1. schema.ts — add column to users table: Add agentSignatureData: text("agent_signature_data") as a nullable column (no .notNull(), no default) to the users pgTable definition. This is the only change to schema.ts.

2. Run migration generation and apply:

cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run db:generate && npm run db:migrate

The generated SQL file will be at drizzle/0008_agent_signature.sql and will contain:

ALTER TABLE "users" ADD COLUMN "agent_signature_data" text;

3. Create GET/PUT route at src/app/api/agent/signature/route.ts:

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

export async function GET() {
  const session = await auth();
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });

  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
    columns: { agentSignatureData: true },
  });

  return Response.json({ agentSignatureData: user?.agentSignatureData ?? null });
}

export async function PUT(req: Request) {
  const session = await auth();
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });

  const { dataURL } = await req.json() as { dataURL: string };

  if (!dataURL || !dataURL.startsWith('data:image/png;base64,')) {
    return Response.json({ error: 'Invalid signature data' }, { status: 422 });
  }
  if (dataURL.length > 50_000) {
    return Response.json({ error: 'Signature data too large' }, { status: 422 });
  }

  await db.update(users)
    .set({ agentSignatureData: dataURL })
    .where(eq(users.id, session.user.id));

  return Response.json({ ok: true });
}

The directory src/app/api/agent/signature/ must be created (mkdir -p). cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 TypeScript compiles clean; migration SQL file exists at drizzle/0008_agent_signature.sql; GET /api/agent/signature returns { agentSignatureData: null } when called with a valid session.

Task 2: AgentSignaturePanel component, profile page, PortalNav link, FieldPlacer token teressa-copeland-homes/src/app/portal/_components/AgentSignaturePanel.tsx teressa-copeland-homes/src/app/portal/(protected)/profile/page.tsx teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx Four files to create/modify:

1. Create AgentSignaturePanel.tsx at src/app/portal/_components/AgentSignaturePanel.tsx:

'use client';
import { useEffect, useRef, useState } from 'react';
import SignaturePad from 'signature_pad';

interface AgentSignaturePanelProps {
  initialData: string | null;
}

export function AgentSignaturePanel({ initialData }: AgentSignaturePanelProps) {
  const [savedData, setSavedData] = useState<string | null>(initialData);
  const [isDrawing, setIsDrawing] = useState(!initialData);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const sigPadRef = useRef<SignaturePad | null>(null);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!isDrawing || !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();
  }, [isDrawing]);

  async function handleSave() {
    if (!sigPadRef.current || sigPadRef.current.isEmpty()) {
      setError('Please draw your signature first');
      return;
    }
    const dataURL = sigPadRef.current.toDataURL('image/png');
    setSaving(true);
    setError(null);
    try {
      const res = await fetch('/api/agent/signature', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ dataURL }),
      });
      if (res.ok) {
        setSavedData(dataURL);
        setIsDrawing(false);
      } else {
        const data = await res.json().catch(() => ({ error: 'Save failed' }));
        setError(data.error ?? 'Save failed');
      }
    } catch {
      setError('Network error — please try again');
    } finally {
      setSaving(false);
    }
  }

  if (!isDrawing && savedData) {
    return (
      <div className="space-y-4">
        <p className="text-sm text-gray-600">Your saved signature:</p>
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={savedData}
          alt="Saved agent signature"
          className="max-h-20 border border-gray-200 rounded bg-white p-2"
        />
        <button
          onClick={() => { setIsDrawing(true); setError(null); }}
          className="px-4 py-2 text-sm bg-white border border-gray-300 rounded hover:bg-gray-50"
        >
          Update Signature
        </button>
      </div>
    );
  }

  return (
    <div className="space-y-3">
      <canvas
        ref={canvasRef}
        className="w-full border border-gray-300 rounded bg-white"
        style={{ height: '140px', touchAction: 'none', display: 'block' }}
      />
      <div className="flex gap-2">
        <button
          onClick={() => sigPadRef.current?.clear()}
          className="px-3 py-2 text-sm bg-white border border-gray-300 rounded hover:bg-gray-50"
        >
          Clear
        </button>
        <button
          onClick={handleSave}
          disabled={saving}
          className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {saving ? 'Saving...' : (savedData ? 'Save Updated Signature' : 'Save Signature')}
        </button>
        {savedData && (
          <button
            onClick={() => { setIsDrawing(false); setError(null); }}
            className="px-3 py-2 text-sm bg-white border border-gray-300 rounded hover:bg-gray-50"
          >
            Cancel
          </button>
        )}
      </div>
      {error && <p className="text-sm text-red-600">{error}</p>}
    </div>
  );
}

2. Create profile/page.tsx at src/app/portal/(protected)/profile/page.tsx:

import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { AgentSignaturePanel } from '../../_components/AgentSignaturePanel';

export default async function ProfilePage() {
  const session = await auth();
  if (!session?.user?.id) redirect('/agent/login');

  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
    columns: { agentSignatureData: true },
  });

  return (
    <div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
      <h1 className="text-2xl font-semibold text-gray-900">Profile</h1>
      <section className="bg-white border border-gray-200 rounded-lg p-6 space-y-4">
        <div>
          <h2 className="text-lg font-medium text-gray-900">Agent Signature</h2>
          <p className="text-sm text-gray-500 mt-1">
            Draw your signature once. It will be embedded in any "Agent Signature" fields when you prepare a document.
          </p>
        </div>
        <AgentSignaturePanel initialData={user?.agentSignatureData ?? null} />
      </section>
    </div>
  );
}

3. Modify PortalNav.tsx — add Profile to navLinks: Find the navLinks array (currently has Dashboard and Clients entries). Add a third entry: { href: '/portal/profile', label: 'Profile' }.

4. Modify FieldPlacer.tsx — add agent-signature palette token: Find the PALETTE_TOKENS array (currently has 5 entries). Add the agent-signature token as the 6th entry:

{ id: 'agent-signature', label: 'Agent Signature', color: '#dc2626' },

This is the only change needed to FieldPlacer.tsx — the validTypes set already includes 'agent-signature'. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 TypeScript compiles clean; npm run dev starts without error; visiting /portal/profile shows the AgentSignaturePanel; PortalNav shows "Profile" link; FieldPlacer palette shows a red "Agent Signature" token.

After both tasks complete, verify the full Plan 01 state:
  1. npx tsc --noEmit passes with zero errors
  2. npm run dev starts without errors
  3. Migration applied: psql -d [db] -c "SELECT column_name FROM information_schema.columns WHERE table_name='users' AND column_name='agent_signature_data';" returns one row
  4. GET /api/agent/signature returns 401 without session; returns { agentSignatureData: null } with valid session
  5. /portal/profile renders the AgentSignaturePanel canvas
  6. Agent can draw on canvas and click "Save Signature" — PUT returns 200, thumbnail appears
  7. Agent can click "Update Signature" — canvas reappears for redraw
  8. FieldPlacer on a document shows "Agent Signature" as a draggable red token

<success_criteria>

  • AGENT-01: Agent can draw and save a signature from /portal/profile; thumbnail shows after save
  • AGENT-02: "Update Signature" button replaces saved signature with a new one (same PUT route)
  • AGENT-03: "Agent Signature" token appears in FieldPlacer palette; placed fields have type 'agent-signature'
  • TypeScript build clean, zero new npm packages </success_criteria>
After completion, create `.planning/phases/11-agent-saved-signature-and-signing-workflow/11-01-SUMMARY.md`