Files
red/.planning/phases/19-template-editor-ui/19-RESEARCH.md
2026-04-06 12:39:01 -06:00

512 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)