docs(19): research phase 19 template editor UI

This commit is contained in:
Chandler Copeland
2026-04-06 12:39:01 -06:00
parent d941c68f58
commit 35da140559

View File

@@ -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>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**FieldPlacer Abstraction**
- D-01: Add `onPersist?: (fields: SignatureFieldData[]) => Promise<void> | 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.
</user_constraints>
---
<phase_requirements>
## 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` |
</phase_requirements>
---
## 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 7490. 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 <TemplatesPageClient templates={rows} />;
}
```
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> | 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<SignatureFieldData[]>(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<string, string>;
onFieldSelect?: (fieldId: string | null) => void;
onFieldValueChange?: (fieldId: string, value: string) => void;
aiPlacementKey?: number;
signers?: DocumentSigner[];
unassignedFieldIds?: Set<string>;
onPersist?: (fields: SignatureFieldData[]) => Promise<void> | 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<string, string>();
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)