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 |
|
true |
|
|
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.mdFrom 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' }
];
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.
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.
npx tsc --noEmitpasses with zero errorsnpm run devstarts without errors- 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 - GET /api/agent/signature returns 401 without session; returns
{ agentSignatureData: null }with valid session - /portal/profile renders the AgentSignaturePanel canvas
- Agent can draw on canvas and click "Save Signature" — PUT returns 200, thumbnail appears
- Agent can click "Update Signature" — canvas reappears for redraw
- 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>