--- phase: 19-template-editor-ui plan: "01" type: execute wave: 1 depends_on: [] files_modified: - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx - teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx - teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts - teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts - teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts autonomous: true requirements: [TMPL-05, TMPL-06, TMPL-07, TMPL-09] must_haves: truths: - "FieldPlacer accepts an onPersist callback that replaces internal persistFields when provided" - "FieldPlacer accepts a fieldsUrl prop that overrides the default /api/documents/{docId}/fields endpoint" - "PdfViewer and PdfViewerWrapper pass through onPersist and fieldsUrl to FieldPlacer plus accept a fileUrl prop for PDF source" - "GET /api/templates/[id]/file streams the form PDF from seeds/forms/" - "GET /api/templates/[id]/fields returns the template signatureFields array" - "POST /api/templates/[id]/ai-prepare extracts blanks and classifies fields with AI then writes to DB" - "Templates link appears in portal nav between Clients and Profile" artifacts: - path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx" provides: "onPersist + fieldsUrl props on FieldPlacerProps interface" contains: "onPersist" - path: "teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts" provides: "PDF file streaming for templates" exports: ["GET"] - path: "teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts" provides: "Template fields JSON endpoint" exports: ["GET"] - path: "teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts" provides: "AI auto-place for templates" exports: ["POST"] key_links: - from: "FieldPlacer.tsx" to: "onPersist callback" via: "conditional call at 4 persistFields sites" pattern: "onPersist.*next.*persistFields" - from: "FieldPlacer.tsx" to: "fieldsUrl || /api/documents/${docId}/fields" via: "useEffect loadFields" pattern: "fieldsUrl.*docId" - from: "PdfViewer.tsx" to: "fileUrl || /api/documents/${docId}/file" via: "Document file prop and download href" pattern: "fileUrl.*docId" --- Add optional `onPersist`, `fieldsUrl`, and `fileUrl` props to the FieldPlacer/PdfViewer/PdfViewerWrapper chain (non-breaking), create three template API routes (file, fields, ai-prepare), and add "Templates" to portal nav. Purpose: Establish the infrastructure that Plan 02's template editor UI will consume. Existing document workflows are unaffected because all new props are optional with backwards-compatible defaults. Output: Modified FieldPlacer/PdfViewer/PdfViewerWrapper with template-mode props; three new API routes under /api/templates/[id]/; PortalNav with Templates link. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/19-template-editor-ui/19-CONTEXT.md @.planning/phases/19-template-editor-ui/19-RESEARCH.md @.planning/phases/18-template-schema-and-crud-api/18-02-SUMMARY.md From src/lib/db/schema.ts: ```typescript export interface SignatureFieldData { id: string; page: number; x: number; y: number; width: number; height: number; type?: SignatureFieldType; signerEmail?: string; hint?: string; // Optional label shown to signer for client-text / client-checkbox fields } export interface DocumentSigner { email: string; color: string; } export const documentTemplates = pgTable("document_templates", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), name: text("name").notNull(), formTemplateId: text("form_template_id").notNull().references(() => formTemplates.id), signatureFields: jsonb("signature_fields").$type(), archivedAt: timestamp("archived_at"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); ``` From FieldPlacer.tsx (current): ```typescript interface FieldPlacerProps { docId: string; pageInfo: PageInfo | null; currentPage: number; children: React.ReactNode; readOnly?: boolean; onFieldsChanged?: () => void; selectedFieldId?: string | null; textFillData?: Record; onFieldSelect?: (fieldId: string | null) => void; onFieldValueChange?: (fieldId: string, value: string) => void; aiPlacementKey?: number; signers?: DocumentSigner[]; unassignedFieldIds?: Set; } // persistFields called at lines 324, 508, 575, 745 ``` From PdfViewerWrapper.tsx (current props): ```typescript { docId: string; docStatus?: string; onFieldsChanged?: () => void; selectedFieldId?: string | null; textFillData?: Record; onFieldSelect?: (fieldId: string | null) => void; onFieldValueChange?: (fieldId: string, value: string) => void; aiPlacementKey?: number; signers?: DocumentSigner[]; unassignedFieldIds?: Set; } ``` From PdfViewer.tsx (same props as PdfViewerWrapper, uses docId at lines 85 and 110): - Line 85: `href={/api/documents/${docId}/file}` (download link) - Line 110: `file={/api/documents/${docId}/file}` (react-pdf Document source) From /api/documents/[id]/ai-prepare/route.ts (pattern to copy): ```typescript 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 }); if (!process.env.OPENAI_API_KEY) return Response.json({ error: '...' }, { status: 503 }); // ... extractBlanks + classifyFieldsWithAI ... } ``` From PortalNav.tsx (current): ```typescript const navLinks = [ { href: "/portal/dashboard", label: "Dashboard" }, { href: "/portal/clients", label: "Clients" }, { href: "/portal/profile", label: "Profile" }, ]; ``` Task 1: Add onPersist + fieldsUrl props to FieldPlacer, fileUrl to PdfViewer/PdfViewerWrapper, and Templates to PortalNav teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx, teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx, teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx, teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx, teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx, teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx, teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx **FieldPlacer.tsx — 3 changes:** 1. Add two optional props to `FieldPlacerProps` interface (after `unassignedFieldIds`): ```typescript onPersist?: (fields: SignatureFieldData[]) => Promise | void; fieldsUrl?: string; ``` 2. Destructure both new props in the function signature with defaults: ```typescript export function FieldPlacer({ ..., onPersist, fieldsUrl }: FieldPlacerProps) { ``` 3. Update the `useEffect` at line ~222 that fetches fields — replace the hardcoded URL: ```typescript const res = await fetch(fieldsUrl ?? `/api/documents/${docId}/fields`); ``` Add `fieldsUrl` to the useEffect dependency array: `[docId, aiPlacementKey, fieldsUrl]` 4. Update all 4 `persistFields` call sites to conditionally call `onPersist` instead: Line ~324 (handleDragEnd, after `setFields(next)`): ```typescript if (onPersist) { onPersist(next); } else { persistFields(docId, next); } ``` Line ~508 (handleZonePointerUp move branch, after `setFields(next)`): ```typescript if (onPersist) { onPersist(next); } else { persistFields(docId, next); } ``` Line ~575 (handleZonePointerUp resize branch, after `setFields(next)`): ```typescript if (onPersist) { onPersist(next); } else { persistFields(docId, next); } ``` Line ~745 (delete button onClick, after `setFields(next)`): ```typescript if (onPersist) { onPersist(next); } else { persistFields(docId, next); } ``` 5. Add `onPersist` to the dependency arrays of the useCallback hooks that contain the 4 call sites: - `handleDragEnd` (line ~327): add `onPersist` to `[fields, pageInfo, currentPage, docId, readOnly, onFieldsChanged, activeSignerEmail]` - `handleZonePointerUp` (line ~578): add `onPersist` to `[docId, onFieldsChanged]` - The delete button's onClick is inline in `renderFields` — `onPersist` is already in scope (function component body), no useCallback change needed. Per D-01 and D-02 from CONTEXT.md. Backwards compatible: when `onPersist` is undefined, all existing callers get the original `persistFields(docId, next)` behavior. **PdfViewer.tsx — 3 changes:** 1. Add three optional props to the inline type annotation of the function: ```typescript onPersist?: (fields: import('@/lib/db/schema').SignatureFieldData[]) => Promise | void; fieldsUrl?: string; fileUrl?: string; ``` (Import `SignatureFieldData` type from `@/lib/db/schema` at the top if not already imported.) 2. Destructure `onPersist`, `fieldsUrl`, `fileUrl` in the function params. 3. Replace the 2 hardcoded file URL usages: - Line 85 download link: `href={fileUrl ?? \`/api/documents/${docId}/file\`}` - Line 110 Document file prop: `file={fileUrl ?? \`/api/documents/${docId}/file\`}` 4. Pass `onPersist` and `fieldsUrl` through to `` (lines 95-107): ```typescript ``` **PdfViewerWrapper.tsx — 3 changes:** 1. Add three optional props to the inline type annotation: ```typescript onPersist?: (fields: import('@/lib/db/schema').SignatureFieldData[]) => Promise | void; fieldsUrl?: string; fileUrl?: string; ``` (Import `SignatureFieldData` from `@/lib/db/schema` at the top.) 2. Destructure `onPersist`, `fieldsUrl`, `fileUrl` in the function params. 3. Pass all three through to ``: ```typescript ``` **PortalNav.tsx — 1 change (per D-17):** Insert Templates link between Clients and Profile in the `navLinks` array: ```typescript const navLinks = [ { href: "/portal/dashboard", label: "Dashboard" }, { href: "/portal/clients", label: "Clients" }, { href: "/portal/templates", label: "Templates" }, { href: "/portal/profile", label: "Profile" }, ]; ``` cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30 - FieldPlacer.tsx contains `onPersist?: (fields: SignatureFieldData[]) => Promise | void` - FieldPlacer.tsx contains `fieldsUrl?: string` - FieldPlacer.tsx contains `fieldsUrl ??` in the loadFields useEffect fetch URL - FieldPlacer.tsx contains `if (onPersist)` at all 4 persistFields call sites (lines ~324, ~508, ~575, ~745) - PdfViewer.tsx contains `fileUrl?: string` - PdfViewer.tsx contains `fileUrl ??` before `/api/documents/${docId}/file` in both the download href and the Document file prop - PdfViewer.tsx passes `onPersist={onPersist}` and `fieldsUrl={fieldsUrl}` to FieldPlacer - PdfViewerWrapper.tsx passes `onPersist`, `fieldsUrl`, `fileUrl` through to PdfViewer - PortalNav.tsx contains `{ href: "/portal/templates", label: "Templates" }` - `npx tsc --noEmit` exits 0 (no type errors) All three viewer components accept optional template-mode props (onPersist, fieldsUrl, fileUrl) with backwards-compatible defaults. PortalNav shows Templates link. TypeScript compiles clean. Task 2: Create three template API routes — file, fields, ai-prepare teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts, teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts, teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts teressa-copeland-homes/src/app/api/templates/[id]/route.ts, teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts, teressa-copeland-homes/src/lib/db/schema.ts, teressa-copeland-homes/src/lib/ai/extract-text.ts, teressa-copeland-homes/src/lib/ai/field-placement.ts **Create `src/app/api/templates/[id]/file/route.ts`** — GET handler that streams the template's source PDF from `seeds/forms/`: ```typescript import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { documentTemplates } from '@/lib/db/schema'; import { and, eq, isNull } from 'drizzle-orm'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms'); export async function GET( _req: Request, { params }: { params: Promise<{ id: string }> } ) { const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 }); const { id } = await params; const template = await db.query.documentTemplates.findFirst({ where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)), with: { formTemplate: true }, }); if (!template?.formTemplate) return Response.json({ error: 'Not found' }, { status: 404 }); const filePath = path.join(SEEDS_FORMS_DIR, template.formTemplate.filename); // Path traversal guard if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 }); try { const file = await readFile(filePath); return new Response(file, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${template.formTemplate.filename}"`, }, }); } catch { return Response.json({ error: 'Form PDF not found on disk' }, { status: 404 }); } } ``` **Create `src/app/api/templates/[id]/fields/route.ts`** — GET handler returning `signatureFields ?? []`: ```typescript import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { documentTemplates } from '@/lib/db/schema'; import { and, eq, isNull } from 'drizzle-orm'; export async function GET( _req: Request, { params }: { params: Promise<{ id: string }> } ) { const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 }); const { id } = await params; const template = await db.query.documentTemplates.findFirst({ where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)), }); if (!template) return Response.json({ error: 'Not found' }, { status: 404 }); return Response.json(template.signatureFields ?? []); } ``` **Create `src/app/api/templates/[id]/ai-prepare/route.ts`** — POST handler (per D-14, D-15): Copy pattern from `/api/documents/[id]/ai-prepare/route.ts` with these differences: - Load from `documentTemplates` (with `formTemplate` relation) instead of `documents` - PDF path is `path.join(SEEDS_FORMS_DIR, template.formTemplate.filename)` instead of uploads dir - No Draft status check (templates have no status) - Pass `null` as client context to `classifyFieldsWithAI` (per D-15 — no client pre-fill) - Write result to `documentTemplates.signatureFields` via `db.update(documentTemplates).set(...)` with explicit `updatedAt: new Date()` - Return `{ fields }` only (no `textFillData` — templates don't use it) ```typescript import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { documentTemplates } from '@/lib/db/schema'; import { and, eq, isNull } from 'drizzle-orm'; import path from 'node:path'; import { extractBlanks } from '@/lib/ai/extract-text'; import { classifyFieldsWithAI } from '@/lib/ai/field-placement'; const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms'); 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 }); if (!process.env.OPENAI_API_KEY) { return Response.json( { error: 'OPENAI_API_KEY not configured. Add it to .env.local.' }, { status: 503 } ); } const { id } = await params; const template = await db.query.documentTemplates.findFirst({ where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)), with: { formTemplate: true }, }); if (!template?.formTemplate) return Response.json({ error: 'Not found' }, { status: 404 }); const filePath = path.join(SEEDS_FORMS_DIR, template.formTemplate.filename); if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 }); try { const blanks = await extractBlanks(filePath); const { fields } = await classifyFieldsWithAI(blanks, null); // null = no client context per D-15 const [updated] = await db .update(documentTemplates) .set({ signatureFields: fields, updatedAt: new Date() }) .where(eq(documentTemplates.id, id)) .returning(); return Response.json({ fields: updated.signatureFields ?? [] }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return Response.json({ error: message }, { status: 500 }); } } ``` All three routes follow the same auth guard + soft-delete filter (`isNull(archivedAt)`) pattern established in Phase 18. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30 - File exists: `src/app/api/templates/[id]/file/route.ts` - File exists: `src/app/api/templates/[id]/fields/route.ts` - File exists: `src/app/api/templates/[id]/ai-prepare/route.ts` - file/route.ts contains `export async function GET` - file/route.ts contains `SEEDS_FORMS_DIR` and `readFile(filePath)` - file/route.ts contains `!filePath.startsWith(SEEDS_FORMS_DIR)` path traversal guard - fields/route.ts contains `export async function GET` - fields/route.ts contains `template.signatureFields ?? []` - ai-prepare/route.ts contains `export async function POST` - ai-prepare/route.ts contains `classifyFieldsWithAI(blanks, null)` (null client context) - ai-prepare/route.ts contains `updatedAt: new Date()` in the update set - ai-prepare/route.ts contains `isNull(documentTemplates.archivedAt)` in the where clause - `npx tsc --noEmit` exits 0 Three template API routes created and type-checking. GET /api/templates/[id]/file streams the form PDF. GET /api/templates/[id]/fields returns signatureFields. POST /api/templates/[id]/ai-prepare runs AI field placement with null client context and writes to DB. 1. `cd teressa-copeland-homes && npx tsc --noEmit` exits 0 — no type errors introduced 2. `npm run build` succeeds — no build-time errors 3. Grep confirms: `onPersist` appears in FieldPlacer, PdfViewer, PdfViewerWrapper 4. Grep confirms: `fieldsUrl` appears in FieldPlacer, PdfViewer, PdfViewerWrapper 5. Grep confirms: `fileUrl` appears in PdfViewer, PdfViewerWrapper 6. Grep confirms: `"/portal/templates"` appears in PortalNav 7. Three new route files exist under `src/app/api/templates/[id]/` - FieldPlacer/PdfViewer/PdfViewerWrapper accept template-mode props without breaking existing document workflows - All three template API routes respond to their HTTP methods with proper auth guards and soft-delete filters - PortalNav shows "Templates" link in the correct position (between Clients and Profile) - TypeScript compiles clean across the entire project After completion, create `.planning/phases/19-template-editor-ui/19-01-SUMMARY.md`