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

34 KiB
Raw Blame History

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.tsxDocumentPageClient.tsx. Template editor follows exactly this structure.

Pattern 2: aiPlacementKey Increment-to-Reload

// 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:

onPersist ? await onPersist(next) : await persistFields(docId, next);

The onPersist callback in TemplatePageClient:

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

// 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)

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}.

// /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.

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

// 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)

// 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)

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

// 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

// 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

// 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.tsxaiPlacementKey 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.tsSignatureFieldData.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)