37 KiB
Phase 11: Agent Saved Signature and Signing Workflow - Research
Researched: 2026-03-21 Domain: Drizzle ORM schema migration, Next.js App Router API routes, signature_pad canvas, @cantoo/pdf-lib PNG embedding, React profile page patterns Confidence: HIGH
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| AGENT-01 | Agent can draw and save a signature to their account profile (drawn once, reused) | DB column agent_signature_data TEXT on users table; GET/PUT /api/agent/signature routes; AgentSignaturePanel component using signature_pad on a canvas; thumbnail rendered from stored base64 PNG |
| AGENT-02 | Agent can update their saved signature at any time | PUT /api/agent/signature is an upsert — same route, no separate "delete first" step; UI shows "Update Signature" button when a saved sig already exists |
| AGENT-03 | Agent can place agent signature field markers on a PDF | FieldPlacer.tsx already has 'agent-signature' in validTypes set but NOT in PALETTE_TOKENS — add one token entry to PALETTE_TOKENS and the entire field-placement pipeline wires up automatically |
| AGENT-04 | Agent applies their saved signature to agent signature fields during document preparation (before sending to client) | preparePdf() already has else if (fieldType === 'agent-signature') { // Skip — handled by Phase 11 } stub; Phase 11 fills that stub: read agentSignatureData from DB and call page.drawImage() at each agent-sig field coordinate |
| </phase_requirements> |
Summary
Phase 11 introduces three tightly coupled features: (1) agent saves a personal signature to their profile account, (2) agent places "agent-signature" field markers on a document, and (3) when the agent prepares a document, the system embeds the saved signature at each agent-signature field coordinate before the document is sent to the client. The client signing session never sees agent-signature fields — they are fully baked in at prepare time.
The codebase is already partially scaffolded for this phase. schema.ts defines SignatureFieldType with 'agent-signature' as a valid variant. isClientVisibleField() correctly returns false for agent-signature fields, so the GET /api/sign/[token] route already filters them out. FieldPlacer.tsx already recognizes 'agent-signature' as a valid type in its validTypes set. prepare-document.ts already has an explicit else if (fieldType === 'agent-signature') { // Skip — handled by Phase 11 } stub. The only missing pieces are: the agentSignatureData column on users, the API routes to read/write it, the AgentSignaturePanel component + profile page, the agent-signature palette token in PALETTE_TOKENS, and the stub fill in preparePdf().
The key architectural decision (already locked in STATE.md) is to store the agent signature as a TEXT column on users containing the raw base64 data URL (data:image/png;base64,...). At 2-8KB per signature PNG, this is well within PostgreSQL TEXT limits. No new file storage, no new S3/blob dependency, no new npm packages. The prepare route reads users.agentSignatureData, decodes the base64 data URL, embeds it via pdfDoc.embedPng() at each agent-signature field coordinate, then writes the prepared PDF. This is the exact same embedding mechanism already used by embedSignatureInPdf() for client signatures.
Primary recommendation: Implement Phase 11 in two sequential plans — Plan A: DB migration + API routes + AgentSignaturePanel + FieldPlacer palette token; Plan B: preparePdf() agent signature embedding + full e2e verification. This ordering ensures the profile page and field placement work before testing the prepare pipeline.
Standard Stack
Core (All Existing — No New Dependencies)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| drizzle-orm | ^0.45.1 | ADD agentSignatureData TEXT column to users table via schema + migration |
Already used for all DB operations; pattern established in phases 9 (property_address) and 10 |
| @cantoo/pdf-lib | ^2.6.3 | pdfDoc.embedPng(dataURL) + page.drawImage() to stamp agent sig at field coordinates |
Already used in prepare-document.ts and embed-signature.ts; embedPng accepts base64 DataURL directly |
| signature_pad | ^5.1.3 | Canvas-based freehand signature drawing in AgentSignaturePanel |
Already used in SignatureModal.tsx for client signatures; identical API |
| next-auth | 5.0.0-beta.30 | auth() call in API routes to get session.user.id for user lookup |
Already used in every authenticated API route; session.user.id confirmed via auth.config.ts JWT callback |
| drizzle-kit | ^0.31.10 | npm run db:generate + npm run db:migrate to apply schema change |
Already the migration workflow; all 7 prior migrations use this pattern |
| next | 16.2.0 | New portal page at /portal/profile + new API routes /api/agent/signature |
Already the app framework |
No New Dependencies
Phase 11 adds zero new npm packages. The storage decision (base64 TEXT on users) eliminates any need for blob storage or image processing libraries. signature_pad is already in the bundle from phase 6. @cantoo/pdf-lib PNG embedding is already used in embed-signature.ts.
Installation:
# No new packages needed
Architecture Patterns
Recommended File Creation/Modification Map
src/lib/db/schema.ts
# Modified: add agentSignatureData TEXT column to users table
drizzle/0008_agent_signature.sql
# New: ALTER TABLE "users" ADD COLUMN "agent_signature_data" text;
# Generated by: npm run db:generate
src/app/api/agent/signature/route.ts
# New: GET returns { agentSignatureData: string|null }
# PUT body: { dataURL: string } — validates + stores in DB
src/app/portal/(protected)/profile/page.tsx
# New: server component — fetches session.user.id → loads user row → renders AgentSignaturePanel
src/app/portal/_components/AgentSignaturePanel.tsx
# New: 'use client' — signature_pad canvas, save/update/clear; shows thumbnail if already saved
src/app/portal/_components/PortalNav.tsx
# Modified: add { href: '/portal/profile', label: 'Profile' } to navLinks
src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
# Modified: add agent-signature token to PALETTE_TOKENS array
src/lib/pdf/prepare-document.ts
# Modified: fill the agent-signature stub — accept agentSignatureData param,
# call pdfDoc.embedPng() + page.drawImage() at each agent-sig field coordinate
Pattern 1: DB Migration — Add TEXT Column to users
What: One ALTER TABLE adds agent_signature_data TEXT (nullable, no default) to the users table. In schema.ts, add agentSignatureData: text("agent_signature_data") to the users pgTable definition.
When to use: Any time a new nullable column is added to an existing table.
Example:
// src/lib/db/schema.ts — modified users table
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(),
agentSignatureData: text("agent_signature_data"), // Added: base64 PNG dataURL or null
});
-- drizzle/0008_agent_signature.sql (generated by npm run db:generate, applied by npm run db:migrate)
ALTER TABLE "users" ADD COLUMN "agent_signature_data" text;
Key insight: The column is nullable — null means no signature saved yet. The UI shows an empty canvas state when null. No default value needed.
Pattern 2: GET/PUT /api/agent/signature Route
What: Two HTTP methods on the same route file. GET reads the current agent's signature data. PUT replaces it (upsert semantics — no INSERT/UPDATE split needed because there is only one user row per agent).
When to use: Simple profile data read/write against the authenticated user's own row.
Example:
// 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 };
// Validate: must be a PNG data URL (base64)
if (!dataURL || !dataURL.startsWith('data:image/png;base64,')) {
return Response.json({ error: 'Invalid signature data' }, { status: 422 });
}
// Size guard: 2-8KB typical; reject anything over 50KB to prevent abuse
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 });
}
Key insight: session.user.id is available because auth.config.ts already sets it in the JWT/session callbacks (token.id = user.id; session.user.id = token.id). No changes needed to auth config.
Pattern 3: AgentSignaturePanel Component
What: A client component that renders a signature_pad canvas. If agentSignatureData is non-null on mount, shows the thumbnail with an "Update Signature" button. If null, shows the empty canvas with a "Save Signature" button. Submits to PUT /api/agent/signature.
When to use: Any profile page where the agent draws and saves a persistent asset.
Example:
// src/app/portal/_components/AgentSignaturePanel.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import SignaturePad from 'signature_pad';
interface AgentSignaturePanelProps {
initialData: string | null; // base64 PNG dataURL from server, or null
}
export function AgentSignaturePanel({ initialData }: AgentSignaturePanelProps) {
const [savedData, setSavedData] = useState<string | null>(initialData);
const [isDrawing, setIsDrawing] = useState(!initialData); // show canvas if no sig yet
const canvasRef = useRef<HTMLCanvasElement>(null);
const sigPadRef = useRef<SignaturePad | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize signature_pad with devicePixelRatio scaling (same pattern as SignatureModal)
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()) return;
const dataURL = sigPadRef.current.toDataURL('image/png');
setSaving(true);
setError(null);
const res = await fetch('/api/agent/signature', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataURL }),
});
setSaving(false);
if (res.ok) {
setSavedData(dataURL);
setIsDrawing(false);
} else {
const err = await res.json().catch(() => ({ error: 'Save failed' }));
setError(err.error ?? 'Save failed');
}
}
if (!isDrawing && savedData) {
// Thumbnail view
return (
<div>
<p>Saved signature:</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={savedData} alt="Saved agent signature" style={{ maxWidth: 300, maxHeight: 80, border: '1px solid #ddd' }} />
<button onClick={() => setIsDrawing(true)}>Update Signature</button>
</div>
);
}
// Drawing view
return (
<div>
<canvas
ref={canvasRef}
style={{ width: '100%', height: '140px', border: '1px solid #ddd', touchAction: 'none', display: 'block' }}
/>
<button onClick={() => sigPadRef.current?.clear()}>Clear</button>
<button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : (savedData ? 'Save Updated Signature' : 'Save Signature')}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
{savedData && (
<button onClick={() => setIsDrawing(false)}>Cancel</button>
)}
</div>
);
}
Pattern 4: Profile Page (Server Component)
What: A new page at /portal/profile under the (protected) route group. Reads the session user ID, queries the users table for agentSignatureData, passes to AgentSignaturePanel.
Example:
// 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>
<h1>Profile</h1>
<section>
<h2>Agent Signature</h2>
<p>Draw your signature once. It will be applied to any "Agent Signature" fields when you prepare a document.</p>
<AgentSignaturePanel initialData={user?.agentSignatureData ?? null} />
</section>
</div>
);
}
Pattern 5: preparePdf() — Agent Signature Embedding
What: Fill the agent-signature stub in preparePdf(). The function already receives sigFields. Phase 11 adds a new parameter agentSignatureData: string | null (the base64 PNG data URL from the DB). For each agent-signature field, call pdfDoc.embedPng(agentSignatureData) and page.drawImage().
When to use: At prepare time, before the document is sent to the client. The client never sees agent-signature fields in their signing session.
Example:
// src/lib/pdf/prepare-document.ts — modified function signature and agent-sig handling
export async function preparePdf(
srcPath: string,
destPath: string,
textFields: Record<string, string>,
sigFields: SignatureFieldData[],
agentSignatureData: string | null, // NEW PARAM: base64 PNG dataURL or null
): Promise<void> {
// ... existing AcroForm strategy A/B logic unchanged ...
// Embed agent signature image once (reused for all agent-sig fields)
let agentSigImage: import('@cantoo/pdf-lib').PDFImage | null = null;
if (agentSignatureData) {
agentSigImage = await pdfDoc.embedPng(agentSignatureData);
// embedPng accepts base64 data URL directly — same as embedSignatureInPdf()
}
for (const field of sigFields) {
const page = pages[field.page - 1];
if (!page) continue;
const fieldType = getFieldType(field);
if (fieldType === 'client-signature') {
// Blue "Sign Here" placeholder — unchanged
// ...
} else if (fieldType === 'initials') {
// Purple "Initials" placeholder — unchanged
// ...
} else if (fieldType === 'checkbox') {
// X mark — unchanged
// ...
} else if (fieldType === 'date') {
// No placeholder drawn (date stamped at POST time) — unchanged
} else if (fieldType === 'text') {
// No marker drawn — unchanged
} else if (fieldType === 'agent-signature') {
// NEW: embed saved signature PNG at field coordinates
if (agentSigImage) {
page.drawImage(agentSigImage, {
x: field.x,
y: field.y,
width: field.width,
height: field.height,
});
}
// If no signature saved, draw a visible warning placeholder
else {
page.drawRectangle({
x: field.x, y: field.y, width: field.width, height: field.height,
borderColor: rgb(0.8, 0.1, 0.1), borderWidth: 1.5,
});
page.drawText('AGENT SIG MISSING', {
x: field.x + 4, y: field.y + 4, size: 7, font: helvetica,
color: rgb(0.8, 0.1, 0.1),
});
}
}
}
// ... save + atomic write unchanged ...
}
Key insight: pdfDoc.embedPng() is already confirmed to accept base64 data URLs directly — this is the exact mechanism used in embedSignatureInPdf(). No Buffer conversion or decoding needed.
Pattern 6: prepare route.ts — Pass agentSignatureData to preparePdf
What: The prepare route at /api/documents/[id]/prepare/route.ts must fetch agentSignatureData from the users table and pass it to preparePdf().
Example:
// src/app/api/documents/[id]/prepare/route.ts — modified POST handler
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
// ... existing body parsing, doc lookup, path resolution unchanged ...
// Fetch agent's saved signature (needed for agent-signature fields)
const agentUser = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { agentSignatureData: true },
});
const agentSignatureData = agentUser?.agentSignatureData ?? null;
await preparePdf(srcPath, destPath, textFields, sigFields, agentSignatureData);
// ... existing DB update, audit log, return unchanged ...
}
Pattern 7: FieldPlacer PALETTE_TOKENS — Add agent-signature Token
What: FieldPlacer.tsx already includes 'agent-signature' in validTypes but deliberately omits it from PALETTE_TOKENS (it was a Phase 11 placeholder). Phase 11 adds the token.
Example:
// src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
// Modified: add one entry to PALETTE_TOKENS
const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [
{ id: 'client-signature', label: 'Signature', color: '#2563eb' }, // blue
{ id: 'initials', label: 'Initials', color: '#7c3aed' }, // purple
{ id: 'checkbox', label: 'Checkbox', color: '#059669' }, // green
{ id: 'date', label: 'Date', color: '#d97706' }, // amber
{ id: 'text', label: 'Text', color: '#64748b' }, // slate
{ id: 'agent-signature', label: 'Agent Signature', color: '#dc2626' }, // red — visually distinct
];
Key insight: The token ID string 'agent-signature' is already recognized as valid in handleDragEnd — it passes through validTypes.has() and is written as type: droppedType on the new SignatureFieldData. No other changes to FieldPlacer are needed.
Anti-Patterns to Avoid
- Storing agent signature as a file on disk. At 2-8KB, a TEXT column is the simplest approach and avoids any file-path management, cleanup, or presigned URL complexity. Do not create an uploads sub-path or use
@vercel/blobfor this. - Including
agentSignatureDatain the client signing page response. The GET/api/sign/[token]route already filters agent-signature fields viaisClientVisibleField(). Do NOT pass the signature image itself to the client. The signature is embedded in the PDF at prepare time — the client never needs it. - Embedding the agent PNG at signing time (POST route). Agent signatures must be invisible to the client — they must be baked into the prepared PDF by
preparePdf(), NOT added in POST/api/sign/[token]. The POST route handles only client-submitted signatures. - Calling
embedPng()once per agent-signature field instead of once per document.pdfDoc.embedPng()is called once and returns aPDFImagereference. The samePDFImageobject can be passed topage.drawImage()multiple times on different pages. Don't re-callembedPng()in a loop. - Not guarding against missing agent signature at prepare time. If the agent hasn't saved a signature yet and tries to prepare a document with agent-signature fields, the system should draw a visible "AGENT SIG MISSING" warning placeholder (not silently skip) so the agent knows they need to save their signature first. Alternatively, block the prepare entirely with a 422 error if agent-signature fields exist but no signature is saved.
- Not adding "Profile" to PortalNav. The profile page lives under
/portal/(protected)/profilebut won't be navigable unlessPortalNav.tsxis updated with a nav link.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| PNG embedding in PDF | Custom base64 decode + raw PDF stream injection | pdfDoc.embedPng(dataURL) from @cantoo/pdf-lib |
Already used in embed-signature.ts; handles transparency, color space, ICC profiles correctly |
| Canvas drawing for agent signature panel | New custom canvas event handler | signature_pad (already in bundle, via SignatureModal.tsx) |
DPR scaling, pressure sensitivity, iOS/Android compatibility already handled |
| Image size validation | Custom file-size check | String length check on the base64 data URL | PNG at reasonable signature size is 2-8KB; dataURL.length > 50_000 is a sufficient guard |
| Session user lookup | Custom JWT decode | auth() from next-auth + session.user.id |
Already set in auth.config.ts JWT/session callbacks; confirmed working in all protected routes |
Key insight: Phase 11 uses zero new libraries. Every primitive needed (canvas drawing, PNG embedding, session auth, DB updates) already exists and is tested in prior phases.
Common Pitfalls
Pitfall 1: preparePdf() Signature Change Breaks Existing Callers
What goes wrong: preparePdf() gains a new agentSignatureData parameter. The only caller today is /api/documents/[id]/prepare/route.ts. If the parameter is added as a required positional argument and the route is not updated simultaneously, TypeScript will catch it at build time — but the planner must treat these two files as a single atomic change.
Why it happens: TypeScript signature mismatch — the route passes 4 args but preparePdf now requires 5.
How to avoid: Update preparePdf() signature and its sole caller in the same plan task. Alternatively, make agentSignatureData optional with a default of null (agentSignatureData: string | null = null) so existing code compiles without change — then update the caller separately.
Warning signs: TypeScript build error: "Expected 5 arguments, but got 4."
Pitfall 2: Agent Signature Missing at Prepare Time — Silent Failure
What goes wrong: Agent places agent-signature fields on a document, then prepares it without ever saving a signature. preparePdf() receives agentSignatureData = null, the if (agentSigImage) branch is skipped, and the prepared PDF has no signature at those coordinates. Agent sends to client. Client signs. Signed PDF has blank spaces where agent signature should be.
Why it happens: No pre-condition check that blocks preparation when agent-sig fields exist but no signature is saved.
How to avoid: Two defense layers: (1) In preparePdf(), draw a visible red "AGENT SIG MISSING" warning rectangle when agentSignatureData is null but there are agent-signature fields — this makes the problem visible. (2) In the prepare route, check if sigFields.some(f => getFieldType(f) === 'agent-signature') AND !agentSignatureData — return a 422 with { error: 'agent-signature-missing' } before calling preparePdf(). Layer 2 is the primary guard; layer 1 is defense in depth.
Warning signs: Prepared PDF shows red placeholder boxes. Prepare API returns 422 with agent-signature-missing.
Pitfall 3: agentSignatureData Column Not Exposed in Session Object
What goes wrong: Developer tries to pass agentSignatureData through the NextAuth session object instead of fetching it separately from the DB. The session only contains id and email per auth.config.ts — it does not (and should not) carry large base64 image data in the JWT.
Why it happens: Attempting to avoid an extra DB query in the prepare route.
How to avoid: Always query users table separately for agentSignatureData in the prepare route. The session provides session.user.id for the WHERE clause. This is the correct pattern — the session JWT stays small. The extra DB query is a single indexed PK lookup (< 1ms).
Warning signs: Session type errors; JWT token size balloon; agentSignatureData always undefined.
Pitfall 4: Base64 Data URL Size Exceeds Reasonable Bounds
What goes wrong: A malformed client sends a very large payload (e.g., a multi-megabyte PNG disguised as a signature) to PUT /api/agent/signature. Without a size guard, this is written to the users.agentSignatureData column, inflating every DB query that touches the users table.
Why it happens: No server-side size validation on the PUT handler.
How to avoid: Validate dataURL.startsWith('data:image/png;base64,') AND dataURL.length <= 50_000 (50KB is 10x a typical signature PNG). Return 422 if either check fails.
Warning signs: DB row size anomalies; abnormally slow queries on users table; Drizzle returning large row payloads.
Pitfall 5: FieldPlacer Shows Agent Signature Token When Not Useful
What goes wrong: The agent opens a document and sees the palette with an "Agent Signature" token, drags it onto the PDF, then prepares the document — but the prepare step fails with 422 because they haven't saved a signature yet. The prepare panel shows an error with no clear path to fix it.
Why it happens: No linkage between the field palette and the signature save status.
How to avoid: Two UX options: (a) The prepare panel fetches GET /api/agent/signature before submitting and shows a warning ("You have agent signature fields but no saved signature — visit Profile to save one") with a link; or (b) the profile page is prominent enough (in PortalNav) that the agent knows to go there first. Option (b) is simpler. The 422 error message from the prepare API should be clear: "No agent signature saved. Go to Profile > Signature to save your signature first."
Warning signs: Prepare fails with 422; error message is not actionable.
Pitfall 6: drawImage Y-Coordinate Mismatch
What goes wrong: Agent places an agent-signature field at a specific visual position in the FieldPlacer. At prepare time, the signature image appears at a different vertical position in the prepared PDF.
Why it happens: PDF coordinate origin is bottom-left (Y increases upward). FieldPlacer.tsx already correctly converts screen coordinates to PDF user space via screenToPdfCoords() when placing fields. The field's stored y is the bottom-left corner of the field in PDF space. page.drawImage({ x, y, width, height }) also uses bottom-left origin — so field.y is the correct y argument.
How to avoid: Use field.x, field.y, field.width, field.height directly from the stored SignatureFieldData. Do NOT invert or adjust Y. This is the same coordinate system already used by embedSignatureInPdf() for client signatures — which already works correctly.
Warning signs: Signature appears in wrong vertical position relative to where the agent placed the field in the FieldPlacer.
Code Examples
Verified patterns from codebase inspection (2026-03-21):
embedPng + drawImage — Confirmed Working Pattern from embed-signature.ts
// Source: src/lib/signing/embed-signature.ts — confirmed working in Phase 6+
// embedPng accepts base64 DataURL directly (no Buffer conversion needed)
const pngImage = await pdfDoc.embedPng(sig.dataURL); // 'data:image/png;base64,...'
page.drawImage(pngImage, {
x: sig.x,
y: sig.y,
width: sig.width,
height: sig.height,
});
signature_pad Canvas Init with DPR Scaling — Confirmed Working Pattern from SignatureModal.tsx
// Source: src/app/sign/[token]/_components/SignatureModal.tsx
// CRITICAL: DPR scaling prevents blurry signatures on Retina/HiDPI displays
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]);
Session User ID Access — Confirmed via auth.config.ts
// auth.config.ts confirms: session.user.id is set from JWT token.id
// 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 });
// session.user.id is the UUID from the users table
Drizzle UPDATE — Single Column Upsert Pattern
// Pattern from Phase 9 (clients.propertyAddress update) — confirmed working
await db.update(users)
.set({ agentSignatureData: dataURL })
.where(eq(users.id, session.user.id));
Migration Pattern — ALTER TABLE ADD COLUMN
-- Pattern from drizzle/0007_equal_nekra.sql (property_address on clients)
-- Phase 11 equivalent:
ALTER TABLE "users" ADD COLUMN "agent_signature_data" text;
FieldPlacer validTypes — Already Contains 'agent-signature'
// Source: src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx line 258
// 'agent-signature' is already in validTypes — Phase 11 only needs to add to PALETTE_TOKENS
const validTypes = new Set<string>(['client-signature', 'initials', 'text', 'checkbox', 'date', 'agent-signature']);
const droppedType: SignatureFieldType = validTypes.has(active.id as string)
? (active.id as SignatureFieldType)
: 'client-signature';
prepare-document.ts agent-signature Stub — Already Exists
// Source: src/lib/pdf/prepare-document.ts line 138-140
// Phase 11 fills this stub:
} else if (fieldType === 'agent-signature') {
// Skip — agent signature handled by Phase 11; no placeholder drawn here
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Agent signature not supported (Phase 10 stub only) | Agent saves signature to profile; embedded at prepare time | Phase 11 | Agent can pre-sign documents before sending to client |
| PALETTE_TOKENS has 5 entries (no agent-sig) | PALETTE_TOKENS has 6 entries including agent-signature | Phase 11 | FieldPlacer exposes the agent-signature token |
| preparePdf() skips agent-signature fields | preparePdf() embeds saved PNG at agent-signature coordinates | Phase 11 | Prepared PDF contains agent's embedded signature |
| users table has no signature column | users table has agent_signature_data TEXT |
Phase 11 (migration 0008) | Agent signature persists across sessions |
Deprecated/outdated after Phase 11:
- The
// Skip — agent signature handled by Phase 11comment stub inprepare-document.ts— replaced by real embedding logic.
Open Questions
-
What to do when agent prepares a document with agent-signature fields but no saved signature?
- What we know:
preparePdf()will receiveagentSignatureData = null. Without a guard, the signature is silently absent in the prepared PDF. - What's unclear: Should the prepare route (a) block with 422 + actionable error, or (b) draw a red warning placeholder and continue?
- Recommendation: Block at the route level with 422 +
{ error: 'agent-signature-missing' }before callingpreparePdf(). This is the most reliable guard. The prepare panel inPreparePanel.tsxshould display the error message with a link to/portal/profile. This is a one-line check in the prepare route before thepreparePdf()call.
- What we know:
-
Should the profile page be a new
/portal/profileroute or a section on the dashboard?- What we know: The portal currently has
/portal/dashboardand/portal/clients. There is no profile page. Adding a distinct route at/portal/profileand a nav link is clean and follows the existing pattern. - What's unclear: Whether adding a third nav item is too much clutter for a single-agent app.
- Recommendation: Create
/portal/profileas a distinct page and add "Profile" toPortalNav.tsxnavLinks. The profile page is the natural home for AGENT-01 and AGENT-02 requirements. A separate page is preferable to adding a section to the dashboard.
- What we know: The portal currently has
-
Should
AgentSignaturePanelsupport the "Type" tab (likeSignatureModal)?- What we know:
SignatureModalhas draw, type, and saved tabs. For the client signing flow, typed signatures are a nice-to-have. For the agent's own saved signature, typed may be useful too. - What's unclear: Whether Phase 11 scope includes the type tab or just draw.
- Recommendation: Phase 11 should implement draw only for simplicity. The type tab can be added later. The primary requirement is "Agent can draw a signature" (AGENT-01) — the type tab is not mentioned.
- What we know:
-
Should the prepare route guard (agent-sig fields but no saved sig) be implemented in Plan A or Plan B?
- What we know: Plan A adds the API routes and DB column. Plan B adds the
preparePdf()embedding. The guard logic lives in the prepare route. - Recommendation: Implement the guard in Plan B — it depends on knowing that
agentSignatureDatais a field that can be null. Plan A establishes the schema and APIs; Plan B completes the pipeline and should include the guard as part of wiring up the prepare route change.
- What we know: Plan A adds the API routes and DB column. Plan B adds the
Sources
Primary (HIGH confidence)
/Users/ccopeland/temp/red/teressa-copeland-homes/src/lib/db/schema.ts(codebase inspection 2026-03-21) —userstable confirmed to have noagentSignatureDatacolumn yet;SignatureFieldTypeunion confirmed to include'agent-signature';isClientVisibleField()confirmed to returnfalseforagent-signature/Users/ccopeland/temp/red/teressa-copeland-homes/src/lib/pdf/prepare-document.ts(codebase inspection 2026-03-21) —agent-signaturestub at line 138 confirmed; function signature confirmed;pdfDoc.embedPngnot yet called (to be added in Phase 11)/Users/ccopeland/temp/red/teressa-copeland-homes/src/lib/signing/embed-signature.ts(codebase inspection 2026-03-21) —pdfDoc.embedPng(sig.dataURL)+page.drawImage()pattern confirmed working; accepts base64 DataURL directly/Users/ccopeland/temp/red/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx(codebase inspection 2026-03-21) —'agent-signature'confirmed invalidTypesset at line 258; absent fromPALETTE_TOKENSat lines 70-76; Phase 11 only needs to add one array entry/Users/ccopeland/temp/red/teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx(codebase inspection 2026-03-21) —signature_padDPR scaling pattern confirmed;sigPadRef.current.toDataURL('image/png')produces base64 PNG DataURL/Users/ccopeland/temp/red/teressa-copeland-homes/src/lib/auth.config.ts(codebase inspection 2026-03-21) —session.user.id = token.id as stringconfirmed; session user ID available in all authenticated API routes/Users/ccopeland/temp/red/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts(codebase inspection 2026-03-21) — sole caller ofpreparePdf(); already hasauth()call andsession.user.idaccessible (fromauth())/Users/ccopeland/temp/red/teressa-copeland-homes/drizzle/0007_equal_nekra.sql(codebase inspection 2026-03-21) —ALTER TABLE "clients" ADD COLUMN "property_address" textpattern confirmed for nullable TEXT column migrations/Users/ccopeland/temp/red/teressa-copeland-homes/package.json(2026-03-21) — signature_pad 5.1.3, @cantoo/pdf-lib 2.6.3, drizzle-orm 0.45.1 confirmed in production deps; zero new packages needed.planning/STATE.md— Decision locked: "Agent signature stored as base64 PNG TEXT column on users table (2-8KB) — no new file storage needed"
Secondary (MEDIUM confidence)
- Phase 10 RESEARCH.md (2026-03-21) —
prepare-document.tsfield loop structure documented; agent-signature skip stub placement confirmed consistent with what was found in codebase
Metadata
Confidence breakdown:
- Standard stack: HIGH — all libraries confirmed via package.json and node_modules source inspection; zero new dependencies; storage decision locked in STATE.md
- Architecture: HIGH — specific file and line numbers identified from direct codebase inspection; the agent-signature stub in
prepare-document.ts, thevalidTypesset inFieldPlacer.tsx, and theisClientVisibleField()filter in schema.ts all confirm the codebase is scaffolded exactly for Phase 11 - Pitfalls: HIGH — all pitfalls derived from reading actual implementation code, not speculation
Research date: 2026-03-21 Valid until: 2026-04-20 (stable stack; no external dependencies that could change)