From 35da1405595a8a3108b45b856226819cdc420eb3 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Mon, 6 Apr 2026 12:39:01 -0600 Subject: [PATCH] docs(19): research phase 19 template editor UI --- .../19-template-editor-ui/19-RESEARCH.md | 511 ++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 .planning/phases/19-template-editor-ui/19-RESEARCH.md diff --git a/.planning/phases/19-template-editor-ui/19-RESEARCH.md b/.planning/phases/19-template-editor-ui/19-RESEARCH.md new file mode 100644 index 0000000..8c698c8 --- /dev/null +++ b/.planning/phases/19-template-editor-ui/19-RESEARCH.md @@ -0,0 +1,511 @@ +# Phase 19: Template Editor UI — Research + +**Researched:** 2026-04-06 +**Domain:** Next.js App Router UI — component abstraction, React state, AI API route +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**FieldPlacer Abstraction** +- D-01: Add `onPersist?: (fields: SignatureFieldData[]) => Promise | void` prop to FieldPlacer. When provided, replaces the internal `persistFields(docId, fields)` call. When absent (all existing consumers), falls back to existing internal `persistFields` behavior — fully backwards compatible. +- D-02: `persistFields` is called in 4 places in FieldPlacer. All 4 must be updated to call `onPersist(fields)` if provided, else `persistFields(docId, fields)`. The internal `persistFields` function stays for backwards compat. +- D-03: FieldPlacer's `signers` prop accepts `DocumentSigner[]` where `email` carries either a real email (document mode) OR a role label (template mode). No type change needed. + +**Role Labels in Template Editor** +- D-04: Role labels are free-form strings. Preset list: "Buyer", "Co-Buyer", "Seller", "Co-Seller". Agent can type any string. +- D-05: Template editor starts with two default role labels pre-configured: "Buyer" (#6366f1 indigo) and "Seller" (#f43f5e rose). Agent can add, remove, or rename. +- D-06: Role colors: `['#6366f1', '#f43f5e', '#10b981', '#f59e0b']` cycling by index. +- D-07: Role labels stored in `documentTemplates.signatureFields` via `field.signerEmail`. Save calls `PATCH /api/templates/[id]` with full `signatureFields` array. +- D-08: "Active role" selector in template editor is identical in behavior to "Active signer" selector in document FieldPlacer — same component, different strings. + +**Template Editor Page** +- D-09: Route: `/portal/templates/[id]` — single page view + edit. No separate `/edit` route. +- D-10: Templates list page at `/portal/templates` — shows all active templates (name, form name, field count, last updated). Click row → open editor. +- D-11: "Templates" appears in the portal top nav (alongside Dashboard, Clients, Profile). +- D-12: Template editor page layout: PDF viewer on the left (full FieldPlacer), slim right panel (TemplatePanel) with: template name (editable), role list (add/remove/rename), AI Auto-place button, Save button. +- D-13: TemplatePanel is a new component (separate from PreparePanel). PreparePanel is document-specific. TemplatePanel is template-specific (roles, save). + +**AI Auto-place for Templates** +- D-14: New route `POST /api/templates/[id]/ai-prepare` — reads `formTemplate.filename` to locate PDF in `seeds/forms/`, calls `extractBlanks(filePath)` + `classifyFieldsWithAI(blanks, null)`, writes result to `documentTemplates.signatureFields` via `PATCH`. +- D-15: No client pre-fill in template AI mode — `classifyFieldsWithAI` receives `null` for client context. +- D-16: AI Auto-place button triggers `POST /api/templates/[id]/ai-prepare`, then reloads FieldPlacer via `aiPlacementKey` increment. + +**Navigation** +- D-17: Portal nav: add "Templates" between "Clients" and "Profile". +- D-18: Templates list page is a server component that fetches from the database directly. + +### Claude's Discretion +- Exact TemplatePanel layout details (spacing, button order) +- Whether to show field count on the editor page header +- Loading states for AI auto-place and save operations + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| TMPL-05 | Agent can open a template in an editor and drag-drop fields onto the PDF (reuses existing FieldPlacer) | FieldPlacer `onPersist` abstraction + TemplatePageClient state owner + PdfViewerWrapper reuse | +| TMPL-06 | Agent can use AI auto-place to populate fields on a template | New `POST /api/templates/[id]/ai-prepare` route; copies pattern from `/api/documents/[id]/ai-prepare` | +| TMPL-07 | Template fields use signer role labels instead of specific email addresses | `signerEmail` field slot already accepts any string; no type change needed; no email validation at FieldPlacer level | +| TMPL-08 | Agent can set text hints on client-text fields in the template | Already implemented in FieldPlacer for client-text type via inline input; template mode inherits this | +| TMPL-09 | Agent can save the template — fields and role assignments are persisted | `onPersist` callback writes to `PATCH /api/templates/[id]`; Phase 18 PATCH route already accepts `signatureFields` | + + +--- + +## Summary + +Phase 19 is a UI-construction phase with one new API route. The primary engineering challenge is the `onPersist` abstraction in FieldPlacer — 4 call sites need to switch from the internal `persistFields(docId, fields)` to `onPersist(fields)` when the prop is provided. Everything else is composition of existing patterns: `TemplatePageClient` mirrors `DocumentPageClient`, `TemplatePanel` mirrors `PreparePanel` in layout, and the new AI route mirrors `/api/documents/[id]/ai-prepare` with one simplification (no client context, PDF comes from `seeds/forms/` instead of `uploads/`). + +The second complexity is the role-label system. Because `DocumentSigner.email` is typed as `string` (not validated as an email address anywhere in FieldPlacer or its props chain), role labels like "Buyer" flow through without any changes to types or validation. The `signers` prop and the active-signer selector in FieldPlacer work purely by string matching — they display whatever is in `signer.email`. This means template mode requires zero type changes in FieldPlacer beyond the `onPersist` addition. + +The PdfViewer/PdfViewerWrapper/FieldPlacer chain is built around a `docId` parameter used for two purposes: (1) fetching the PDF file at `/api/documents/${docId}/file`, and (2) fetching/saving fields at `/api/documents/${docId}/fields`. For template mode, neither of those endpoints applies. The template PDF comes from `seeds/forms/` via the template's associated `formTemplate.filename`. This means TemplatePageClient must NOT pass the `docId` prop to PdfViewerWrapper/PdfViewer the same way — a new `templateId` + `formFilename` approach is needed, or a new PdfViewer variant for templates. This is the most significant design gap to resolve in planning. + +**Primary recommendation:** Build a minimal `TemplatePdfViewer` (not a full rewrite — just a PdfViewer clone that accepts `templateId` and `formFilename` instead of `docId`, serving the PDF from `/api/templates/[id]/file`) to avoid mutating the existing viewer chain. Alternatively, add a `fileUrl` override prop to PdfViewerWrapper/PdfViewer so template mode provides the direct URL. + +--- + +## Standard Stack + +### Core (all pre-installed, no new dependencies) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| react-pdf / pdfjs-dist | installed | PDF rendering in browser | Already used throughout portal viewer | +| @dnd-kit/core | installed | Drag-and-drop field placement | FieldPlacer already uses this | +| drizzle-orm | installed | DB queries for templates list and editor load | All DB access uses Drizzle | +| next/navigation | built-in | `useRouter` for post-create redirect, `usePathname` for nav active state | Existing PortalNav pattern | + +### No new packages needed +Phase 19 introduces zero new npm dependencies. All required libraries are already installed. + +**Installation:** None required. + +--- + +## Architecture Patterns + +### Existing Patterns Being Reused + +**Pattern 1: Server Component Page → PageClient State Owner** +``` +page.tsx (async server component, fetches from DB) + → PageClient.tsx (client component, owns all useState) + → PdfViewerWrapper → PdfViewer → FieldPlacer (left column) + → Panel component (right column) +``` +Established in `documents/[docId]/page.tsx` → `DocumentPageClient.tsx`. Template editor follows exactly this structure. + +**Pattern 2: `aiPlacementKey` Increment-to-Reload** +```typescript +// TemplatePageClient.tsx +const [aiPlacementKey, setAiPlacementKey] = useState(0); + +const handleAiAutoPlace = useCallback(async () => { + await fetch(`/api/templates/${templateId}/ai-prepare`, { method: 'POST' }); + setAiPlacementKey(k => k + 1); // triggers FieldPlacer useEffect to re-fetch fields +}, [templateId]); +``` +Source: `DocumentPageClient.tsx` lines 74–90. Identical pattern for template AI auto-place. + +**Pattern 3: FieldPlacer `onPersist` Abstraction** +The `persistFields` function is called in exactly 4 places in FieldPlacer: +1. Line ~324: `persistFields(docId, next)` — after drag-drop of a new field +2. Line ~508: `persistFields(docId, next)` — after move (pointer up) +3. Line ~575: `persistFields(docId, next)` — after resize (pointer up) +4. Line ~745: `persistFields(docId, next)` — after delete button click + +Each call site becomes: +```typescript +onPersist ? await onPersist(next) : await persistFields(docId, next); +``` + +The `onPersist` callback in TemplatePageClient: +```typescript +const handlePersist = useCallback(async (fields: SignatureFieldData[]) => { + await fetch(`/api/templates/${templateId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ signatureFields: fields }), + }); +}, [templateId]); +``` + +**Pattern 4: PortalNav `navLinks` Array Extension** +```typescript +// PortalNav.tsx — current navLinks +const navLinks = [ + { href: "/portal/dashboard", label: "Dashboard" }, + { href: "/portal/clients", label: "Clients" }, + { href: "/portal/profile", label: "Profile" }, +]; +// Add "Templates" between Clients and Profile: + { href: "/portal/templates", label: "Templates" }, +``` +No other changes to PortalNav. + +**Pattern 5: Server Component List Page (clients/page.tsx)** +```typescript +export default async function TemplatesPage() { + const rows = await db.select({ ... }) + .from(documentTemplates) + .leftJoin(formTemplates, eq(documentTemplates.formTemplateId, formTemplates.id)) + .where(isNull(documentTemplates.archivedAt)); + return ; +} +``` +Mirrors `clients/page.tsx` exactly. Server fetches; client component handles interaction. + +**Pattern 6: AI Route (documents/[id]/ai-prepare)** +Template route is simpler: no `doc.status !== 'Draft'` guard, no `textFillData` return, PDF path from `seeds/forms/${form.filename}` instead of `uploads/${doc.filePath}`. + +```typescript +// /api/templates/[id]/ai-prepare/route.ts +const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms'); +const filePath = path.join(SEEDS_FORMS_DIR, form.filename); +// path traversal guard +if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 }); +const blanks = await extractBlanks(filePath); +const { fields } = await classifyFieldsWithAI(blanks, null); // null = no client context +// PATCH template's signatureFields +await db.update(documentTemplates).set({ signatureFields: fields }).where(eq(documentTemplates.id, id)); +return Response.json({ fields }); +``` + +### Critical Design Gap: PDF File Serving for Templates + +**Problem:** PdfViewer currently fetches the PDF from `/api/documents/${docId}/file`. Templates are not documents — their PDF lives in `seeds/forms/${form.filename}`. + +**Two options:** + +Option A — New API route `GET /api/templates/[id]/file` that reads `seeds/forms/${form.filename}` and streams it. PdfViewerWrapper accepts an optional `fileUrl` prop that overrides the default `/api/documents/${docId}/file`. TemplatePageClient passes `/api/templates/${templateId}/file`. + +Option B — New `TemplatePdfViewer` component that clones PdfViewer but uses the template file URL. Higher duplication risk. + +**Recommendation: Option A** — add `fileUrl?: string` and `onPersist?: (fields: SignatureFieldData[]) => Promise | void` as optional props to PdfViewerWrapper and PdfViewer. Both default to the existing document behavior when absent. This is one extra prop per component and avoids any duplication. + +**Fields fetch for templates:** FieldPlacer fetches fields from `/api/documents/${docId}/fields`. In template mode, fields come from `documentTemplates.signatureFields`. Two sub-options: +- Sub-option A: FieldPlacer also accepts an optional `initialFields?: SignatureFieldData[]` prop — TemplatePageClient passes the fields loaded from DB by the server component, bypassing the API fetch. FieldPlacer uses `initialFields` as the initial state when provided, skipping the `useEffect` fetch. +- Sub-option B: Add a `GET /api/templates/[id]/fields` route that returns `template.signatureFields`. + +**Recommendation: Sub-option A (initialFields prop)** — the server component already fetches the template including its `signatureFields`. Passing them as a prop avoids an extra round-trip and keeps the component self-contained. The `useEffect` that fetches fields from `/api/documents/${docId}/fields` runs only when `initialFields` is absent (or `aiPlacementKey` increments). When `aiPlacementKey` increments in template mode, TemplatePageClient must re-fetch from the DB (or from the PATCH response) and update `initialFields`. + +**Revised approach for `aiPlacementKey` in template mode:** After the AI route returns, TemplatePageClient receives the updated `fields` array in the response body and stores them in local state (`const [fields, setFields] = useState(initialFields)`). Incrementing `aiPlacementKey` alone would try to re-fetch from `/api/documents/${docId}/fields` — which doesn't apply. Instead, TemplatePageClient stores the `fields` state and passes it as `initialFields`; after AI auto-place, it updates `fields` from the response and resets `aiPlacementKey` to force FieldPlacer to re-initialize from the new `initialFields`. + +This is a notable difference from `DocumentPageClient` — in document mode, the server-side PATCH writes the fields to DB and `aiPlacementKey` increment causes FieldPlacer to re-fetch from the same `/api/documents/[id]/fields` endpoint. In template mode, the route similarly writes to DB via PATCH, so the simpler approach is: after AI auto-place response, use `setAiPlacementKey(k => k + 1)` as-is — but FieldPlacer needs a fallback fields endpoint for templates. **Simplest resolution: add `GET /api/templates/[id]/fields`** that returns `documentTemplates.signatureFields ?? []`. Then `aiPlacementKey` increment triggers the existing `useEffect` as long as FieldPlacer knows to call `/api/templates/${docId}/fields` instead of `/api/documents/${docId}/fields`. + +This suggests: `docId` in FieldPlacer should either remain as-is with the caller passing the route prefix, OR FieldPlacer gets a `fieldsUrl` prop. **Cleanest solution: FieldPlacer gets a `fieldsUrl?: string` prop** that defaults to `/api/documents/${docId}/fields` when absent, and callers in template mode pass `/api/templates/${templateId}/fields`. Similarly `persistFields` is replaced by `onPersist` — so no `PUT /api/documents/${docId}/fields` call happens in template mode. + +### Recommended Project Structure (new files) + +``` +src/app/portal/(protected)/templates/ +├── page.tsx # server component: list page +├── [id]/ +│ ├── page.tsx # server component: editor loader +│ └── _components/ +│ ├── TemplatePageClient.tsx # state owner (mirrors DocumentPageClient) +│ └── TemplatePanel.tsx # right panel (roles, AI, save) +src/app/api/templates/[id]/ +├── ai-prepare/ +│ └── route.ts # POST — AI auto-place for templates +└── fields/ +│ └── route.ts # GET — return signatureFields for aiPlacementKey reload +└── file/ + └── route.ts # GET — stream PDF from seeds/forms/ +``` + +**Modified files:** +- `src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` — add `onPersist` + `fieldsUrl` props +- `src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx` — add `fileUrl` + `onPersist` + `fieldsUrl` prop pass-throughs +- `src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx` — add `fileUrl` + `onPersist` + `fieldsUrl` prop pass-throughs +- `src/app/portal/_components/PortalNav.tsx` — add "Templates" to navLinks + +### Anti-Patterns to Avoid + +- **Duplicating PdfViewer/FieldPlacer:** Do not create `TemplatePdfViewer.tsx` or `TemplateFieldPlacer.tsx`. The prop-extension approach (`fileUrl`, `fieldsUrl`, `onPersist`) keeps a single implementation. +- **Email validation on role labels:** There is no email validation in FieldPlacer, PdfViewerWrapper, or PdfViewer — confirmed by grep. Role labels like "Buyer" flow through cleanly. Do NOT add email validation when adding the template mode. +- **Storing hints in textFillData:** TMPL-08 requires text hints on client-text fields. Hints are stored in `field.hint` on the `SignatureFieldData` object, not in `textFillData`. The existing FieldPlacer client-text inline input already stores values via `onFieldValueChange` which maps to `textFillData[field.id]`. In template mode, the agent types a hint — this must be saved as `field.hint` within the `signatureFields` JSONB, not as a text fill value. The existing client-text inline edit in FieldPlacer sets `textFillData[field.id]` (the hint displayed on the signing page) but does NOT write to `field.hint`. **Resolution:** In template mode, treat the client-text inline input value as the hint — when `onPersist` is called, the `fields` array should include `field.hint` set from whatever is in the text input for that field. This requires FieldPlacer to propagate the inline input value into the field object before persisting — a small addition to the delete/persist logic. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| PDF rendering | Custom canvas renderer | `react-pdf` (already installed) | Complex page/scale/text layer handling already solved | +| Drag and drop fields | Custom pointer events | `@dnd-kit/core` (already installed) | Edge cases: touch, scroll, iframe, accessibility | +| Field coordinate math | Re-derive screen↔PDF | Existing `screenToPdfCoords`/`pdfToScreenCoords` in FieldPlacer | Already handles Y-axis flip, scale, canvas offset | +| AI field extraction | New PDF parser | `extractBlanks` + `classifyFieldsWithAI` (already implemented) | Phase 13 work — reused as-is | +| Role color cycling | Custom color picker | SIGNER_COLORS constant `['#6366f1', '#f43f5e', '#10b981', '#f59e0b']` | Same palette as document signers | +| Soft-delete confirmation | Custom dialog | `ConfirmDialog` component (already in `portal/_components/`) | Existing pattern: phase 18 template delete, clients delete | + +--- + +## Common Pitfalls + +### Pitfall 1: FieldPlacer fields fetch URL hardcoded to documents +**What goes wrong:** FieldPlacer's `useEffect` calls `/api/documents/${docId}/fields`. In template mode, this 404s (no such document). +**Why it happens:** `docId` prop was originally always a document ID. +**How to avoid:** Add a `fieldsUrl?: string` prop to FieldPlacer with default value `/api/documents/${docId}/fields`. Template callers pass `/api/templates/${templateId}/fields`. This requires a new `GET /api/templates/[id]/fields` route. +**Warning signs:** 404 errors in browser console when opening the template editor. + +### Pitfall 2: `onPersist` dependency in `handleDragEnd` closure +**What goes wrong:** `handleDragEnd` in FieldPlacer is a `useCallback` with a dependency array that includes `docId` and `onFieldsChanged`. Adding `onPersist` changes the function signature — if `onPersist` is not included in the dependency array, the callback captures a stale closure (always sees the initial `onPersist` value). +**Why it happens:** React hook dependencies. +**How to avoid:** Add `onPersist` to the `useCallback` dependency array in all 4 call-site functions (`handleDragEnd`, `handleZonePointerUp` for move, `handleZonePointerUp` for resize, the delete button onClick). For the delete button (inline in `renderFields`), `onPersist` is already available in scope — ensure it's referenced. +**Warning signs:** Saving in template mode always calls the old internal `persistFields` despite `onPersist` being provided. + +### Pitfall 3: Template name rename updates DB immediately vs on Save +**What goes wrong:** If template name is an uncontrolled input that calls `PATCH /api/templates/[id]` on every keystroke (debounced or not), the save button behavior becomes confusing. If it's only saved on "Save Template" click, it feels decoupled from field saves. +**Recommendation:** Template name edits call `PATCH /api/templates/[id]` on blur (like a settings form). The "Save Template" button saves only `signatureFields` (field layout). These are two distinct PATCH calls — consistent with how PreparePanel works (signers are saved separately from fields). +**Warning signs:** Agent renames template, clicks Save, and the old name reappears. + +### Pitfall 4: Seeds/forms path in Docker container +**What goes wrong:** `path.join(process.cwd(), 'seeds', 'forms')` works in local dev. In Docker, `cwd()` is `/app` and the Dockerfile copies `seeds/` to `/app/seeds` — so the path resolves correctly. This was already confirmed in Phase 17 research. +**How to avoid:** Use `path.join(process.cwd(), 'seeds', 'forms', form.filename)` — identical pattern to how forms are served elsewhere. No special Docker configuration needed. +**Warning signs:** 404 in AI auto-place saying "form PDF not found" — check `process.cwd()` in the container. + +### Pitfall 5: Default roles not persisted until agent saves +**What goes wrong:** TemplatePageClient initializes two default roles ("Buyer", "Seller") in local state. If the agent opens the editor, uses AI auto-place (which writes fields to DB), but never clicks Save — the role assignments in the fields' `signerEmail` slots refer to "Buyer"/"Seller" which is fine (that's template mode). But if the template was created with 0 roles and the agent hasn't saved yet, the active-signer selector in FieldPlacer shows nothing and newly placed fields have no `signerEmail`. +**How to avoid:** Initialize `signers` state from the template's existing `signatureFields` — derive unique role labels from `field.signerEmail` values already stored in the DB. If no fields exist yet, default to ["Buyer", "Seller"]. This ensures refreshing the editor page restores the role list correctly. +**Warning signs:** After refresh, role pills disappear but fields still have `signerEmail` values. + +### Pitfall 6: hint vs textFillData confusion +**What goes wrong:** In document mode, `textFillData[field.id]` holds the agent-entered value that gets burned into the PDF. In template mode for client-text fields, the agent enters a hint (placeholder label for the signer). If the hint is stored only in `textFillData` (ephemeral state) and not in `field.hint` within `signatureFields`, it's lost when the template is applied to a document. +**How to avoid:** When `onPersist` is called (template mode), FieldPlacer should merge any `textFillData[field.id]` values for client-text fields into `field.hint` before persisting. Alternatively, TemplatePageClient sets `onPersist` to a wrapper that applies the merge before calling the PATCH. Either way, `field.hint` in the stored `signatureFields` must carry the hint text, NOT `textFillData`. +**Warning signs:** Template hints don't appear when template is applied to a document in Phase 20. + +--- + +## Code Examples + +### onPersist prop addition to FieldPlacer + +```typescript +// Source: FieldPlacer.tsx FieldPlacerProps interface — add one line +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; + onPersist?: (fields: SignatureFieldData[]) => Promise | void; // NEW + fieldsUrl?: string; // NEW — defaults to /api/documents/${docId}/fields +} +``` + +### Call site replacement (one of 4) + +```typescript +// Before (line ~324 in FieldPlacer, handleDragEnd) +persistFields(docId, next); + +// After +if (onPersist) { + await onPersist(next); +} else { + await persistFields(docId, next); +} +``` + +### TemplatePageClient onPersist (with hint merge) + +```typescript +const handlePersist = useCallback(async (rawFields: SignatureFieldData[]) => { + // Merge textFillData hints into client-text field.hint before saving to template + const fieldsWithHints = rawFields.map(f => + f.type === 'client-text' && textFillData[f.id] + ? { ...f, hint: textFillData[f.id] } + : f + ); + await fetch(`/api/templates/${templateId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ signatureFields: fieldsWithHints }), + }); +}, [templateId, textFillData]); +``` + +### GET /api/templates/[id]/fields route + +```typescript +// src/app/api/templates/[id]/fields/route.ts +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 ?? []); +} +``` + +### GET /api/templates/[id]/file route + +```typescript +// src/app/api/templates/[id]/file/route.ts +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); + if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 }); + const file = await fs.readFile(filePath); + return new Response(file, { headers: { 'Content-Type': 'application/pdf' } }); +} +``` + +### Deriving initial roles from existing signatureFields + +```typescript +// TemplatePageClient.tsx — derive roles from stored fields +const ROLE_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b']; +const DEFAULT_ROLES: DocumentSigner[] = [ + { email: 'Buyer', color: ROLE_COLORS[0] }, + { email: 'Seller', color: ROLE_COLORS[1] }, +]; + +function deriveRolesFromFields(fields: SignatureFieldData[]): DocumentSigner[] { + const seen = new Map(); + fields.forEach(f => { + if (f.signerEmail && !seen.has(f.signerEmail)) { + seen.set(f.signerEmail, ROLE_COLORS[seen.size % ROLE_COLORS.length]); + } + }); + if (seen.size === 0) return DEFAULT_ROLES; + return Array.from(seen.entries()).map(([email, color]) => ({ email, color })); +} +``` + +--- + +## Environment Availability + +Step 2.6: SKIPPED — Phase 19 is UI/code changes only. No new external dependencies. All required tools (Node.js, pdfjs-dist, @dnd-kit/core, Drizzle, OpenAI SDK) confirmed present from prior phases. + +--- + +## Validation Architecture + +> `workflow.nyquist_validation` key is absent from `.planning/config.json` — treating as enabled. + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | None detected — no jest.config, vitest.config, or pytest.ini found in repo | +| Config file | None | +| Quick run command | Manual browser verification (established project pattern) | +| Full suite command | Manual E2E verification at phase gate | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| TMPL-05 | Agent opens template, drags field onto PDF | manual-only | browser: open /portal/templates/[id], drag a field | N/A | +| TMPL-06 | AI Auto-place populates fields | manual-only | browser: click "AI Auto-place Fields", verify fields appear | N/A | +| TMPL-07 | Role labels (not emails) in signerEmail slots | manual-only | browser: add "Buyer" role, place a field, verify signerEmail="Buyer" stored in DB | N/A | +| TMPL-08 | Text hint on client-text field survives save | manual-only | browser: place client-text, click it, type hint, save, refresh, verify hint persists | N/A | +| TMPL-09 | Save persists fields and roles | manual-only | browser: save, refresh, verify field positions match | N/A | + +**No automated test infrastructure exists in this project.** All verification is manual E2E (established pattern from prior phases: Phase 11, 11.1, 12.1 all concluded with "Plan 03: pure human E2E verification"). + +### Wave 0 Gaps +None — no test framework to install; project relies on manual verification. + +--- + +## Project Constraints (from CLAUDE.md) + +These directives from `./teressa-copeland-homes/CLAUDE.md` apply to all implementation work: + +1. **Architecture inspection before planning** — planner must inspect existing patterns before proposing task structure. This research document satisfies that requirement. +2. **Extend existing patterns by default** — no new folder structures, no new workflow styles. Template editor follows `documents/[docId]/` pattern exactly. +3. **Required output sections before implementation** — each plan must include: Existing Architecture, Similar Implementations Found, Reuse vs New Pattern Decision, Persona Findings, Synthesized Plan. +4. **No generic implementations** — all solutions must reference real repo structure (enforced: all code examples above reference actual file paths and line numbers). +5. **Multi-persona research required** — plans must apply Skeptical, Customer, QA, Security, and Implementation Engineer perspectives. +6. **Deviation must be justified** — the `fieldsUrl` prop addition and `GET /api/templates/[id]/fields` route deviate from the CONTEXT.md explicit plan. Justification documented above (Pitfall 1 and Architecture Patterns sections). +7. **Consistency is mandatory** — inline styles, no shadcn, same color palette, same spacing scale (enforced by 19-UI-SPEC.md which was generated and confirmed before research). + +--- + +## Open Questions + +1. **hint persistence via onPersist vs separate save action** + - What we know: `field.hint` exists in `SignatureFieldData` schema. FieldPlacer's client-text inline input currently writes to `textFillData[field.id]`, not to `field.hint`. + - What's unclear: Should the hint be merged into `field.hint` by TemplatePageClient's `onPersist` wrapper (cleanest, no FieldPlacer change needed) or should FieldPlacer itself update `field.hint` in the fields array when the client-text input changes? + - Recommendation: Merge in TemplatePageClient's `onPersist` wrapper to avoid FieldPlacer changes beyond the `onPersist` prop addition. Document the pattern clearly in the plan. + +2. **New Template creation flow from the list page** + - What we know: CONTEXT.md D-10 says "click a row to open editor". The UI-SPEC says "+ New Template" button opens a modal. Phase 18 CRUD API (POST /api/templates) already exists. + - What's unclear: Which modal component handles template creation? Reuse `AddDocumentModal` pattern, or build a minimal `AddTemplateModal`? + - Recommendation: Build `AddTemplateModal` as a self-contained component inside the templates list page client component. It needs only: template name input, form selector (from `GET /api/forms` or inline formTemplates query), and a Create button. The form selector can reuse the existing forms library query pattern. + +3. **`fieldsUrl` vs `initialFields` for template field loading** + - What we know: Two approaches were analyzed above. `fieldsUrl` requires a new API route but fits cleanly into FieldPlacer's existing `useEffect` pattern. `initialFields` avoids the extra route but requires managing field state at two levels. + - Recommendation: `fieldsUrl` prop + `GET /api/templates/[id]/fields` route. Simpler mental model, consistent with existing fetch pattern, and the route is trivially small (5 lines). + +--- + +## Sources + +### Primary (HIGH confidence) +- Direct source inspection: `FieldPlacer.tsx` (822 lines, all 4 `persistFields` call sites read) +- Direct source inspection: `DocumentPageClient.tsx` — `aiPlacementKey` pattern +- Direct source inspection: `PdfViewer.tsx` + `PdfViewerWrapper.tsx` — prop chain +- Direct source inspection: `/api/documents/[id]/ai-prepare/route.ts` — AI route pattern +- Direct source inspection: `/api/templates/[id]/route.ts` + `/api/templates/route.ts` — Phase 18 CRUD +- Direct source inspection: `schema.ts` — `SignatureFieldData.hint`, `DocumentSigner`, `documentTemplates` table +- Direct source inspection: `PortalNav.tsx` — navLinks array structure +- Direct source inspection: `clients/page.tsx` — server component list page pattern +- Direct source inspection: `19-UI-SPEC.md` — visual and interaction contract (pre-approved) + +### Secondary (MEDIUM confidence) +- `STATE.md` decisions log — confirms no email validation in FieldPlacer, role-label strategy +- `REQUIREMENTS.md` — TMPL-05 through TMPL-09 requirement text + +### Tertiary (LOW confidence) +- None — all findings are from direct source inspection of the repo. + +--- + +## Metadata + +**Confidence breakdown:** +- FieldPlacer modification approach: HIGH — all 4 call sites confirmed by reading source +- New route patterns: HIGH — copying confirmed working patterns from Phase 13 and Phase 18 +- Template PDF serving: HIGH — seeds/forms dir confirmed present, Docker path confirmed by Phase 17 +- Role-label flow: HIGH — no email validation found in FieldPlacer/PdfViewer/PdfViewerWrapper chain +- hint merge approach: MEDIUM — `field.hint` exists in schema but current FieldPlacer does not write to it; the merge-in-onPersist approach is a design recommendation not yet verified by an implementer + +**Research date:** 2026-04-06 +**Valid until:** 2026-05-06 (stable codebase, no fast-moving dependencies)