Files
red/.planning/phases/11-agent-saved-signature-and-signing-workflow/11-RESEARCH.md
2026-03-21 13:48:26 -06:00

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

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

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