656 lines
37 KiB
Markdown
656 lines
37 KiB
Markdown
# 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:**
|
|
```bash
|
|
# 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:**
|
|
```typescript
|
|
// 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
|
|
});
|
|
```
|
|
|
|
```sql
|
|
-- 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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/blob` for this.
|
|
- **Including `agentSignatureData` in the client signing page response.** The GET `/api/sign/[token]` route already filters agent-signature fields via `isClientVisibleField()`. 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 a `PDFImage` reference. The same `PDFImage` object can be passed to `page.drawImage()` multiple times on different pages. Don't re-call `embedPng()` 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)/profile` but won't be navigable unless `PortalNav.tsx` is 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```sql
|
|
-- 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'
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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 11` comment stub in `prepare-document.ts` — replaced by real embedding logic.
|
|
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
1. **What to do when agent prepares a document with agent-signature fields but no saved signature?**
|
|
- What we know: `preparePdf()` will receive `agentSignatureData = 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 calling `preparePdf()`. This is the most reliable guard. The prepare panel in `PreparePanel.tsx` should display the error message with a link to `/portal/profile`. This is a one-line check in the prepare route before the `preparePdf()` call.
|
|
|
|
2. **Should the profile page be a new `/portal/profile` route or a section on the dashboard?**
|
|
- What we know: The portal currently has `/portal/dashboard` and `/portal/clients`. There is no profile page. Adding a distinct route at `/portal/profile` and 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/profile` as a distinct page and add "Profile" to `PortalNav.tsx` navLinks. 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.
|
|
|
|
3. **Should `AgentSignaturePanel` support the "Type" tab (like `SignatureModal`)?**
|
|
- What we know: `SignatureModal` has 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.
|
|
|
|
4. **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 `agentSignatureData` is 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.
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- `/Users/ccopeland/temp/red/teressa-copeland-homes/src/lib/db/schema.ts` (codebase inspection 2026-03-21) — `users` table confirmed to have no `agentSignatureData` column yet; `SignatureFieldType` union confirmed to include `'agent-signature'`; `isClientVisibleField()` confirmed to return `false` for `agent-signature`
|
|
- `/Users/ccopeland/temp/red/teressa-copeland-homes/src/lib/pdf/prepare-document.ts` (codebase inspection 2026-03-21) — `agent-signature` stub at line 138 confirmed; function signature confirmed; `pdfDoc.embedPng` not 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 in `validTypes` set at line 258; absent from `PALETTE_TOKENS` at 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_pad` DPR 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 string` confirmed; 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 of `preparePdf()`; already has `auth()` call and `session.user.id` accessible (from `auth()`)
|
|
- `/Users/ccopeland/temp/red/teressa-copeland-homes/drizzle/0007_equal_nekra.sql` (codebase inspection 2026-03-21) — `ALTER TABLE "clients" ADD COLUMN "property_address" text` pattern 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.ts` field 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`, the `validTypes` set in `FieldPlacer.tsx`, and the `isClientVisibleField()` 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)
|