docs(19): research phase 19 template editor UI
This commit is contained in:
511
.planning/phases/19-template-editor-ui/19-RESEARCH.md
Normal file
511
.planning/phases/19-template-editor-ui/19-RESEARCH.md
Normal 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 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 <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)
|
||||
Reference in New Issue
Block a user