diff --git a/.planning/phases/20-apply-template-and-portal-nav/20-RESEARCH.md b/.planning/phases/20-apply-template-and-portal-nav/20-RESEARCH.md new file mode 100644 index 0000000..e91b6ca --- /dev/null +++ b/.planning/phases/20-apply-template-and-portal-nav/20-RESEARCH.md @@ -0,0 +1,573 @@ +# Phase 20: Apply Template and Portal Nav - Research + +**Researched:** 2026-04-06 +**Domain:** Next.js App Router, Drizzle ORM, React client-state wiring, JSONB field snapshot copy +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**AddDocumentModal Redesign** +- D-01: Modal gets two tabs: "Forms Library" (existing, unchanged) and "My Templates" (new). Tab switching is client-side state — no page reload. +- D-02: "My Templates" fetches from `GET /api/templates`. Each row shows: template name, form name, field count. Agent clicks a row to select it. +- D-03: No second "role mapping" step in the modal. Agent picks template → clicks "Add Document" → document created immediately. Role assignment happens in PreparePanel (existing flow). +- D-04: "Forms Library" tab, "Or upload a custom PDF" section, and all existing modal behavior remain completely unchanged. Pure additive change to `AddDocumentModal.tsx`. + +**Apply Template Operation (API)** +- D-05: `POST /api/documents` accepts a new optional body field `documentTemplateId: string`. When present, server reads template's `signatureFields`, copies with new UUIDs, stores as new document's fields. +- D-06: Role-to-email mapping: server reads role labels from copied fields' `signerEmail` values. Builds `signers` array: role[0] → `client.email`, role[1] → `client.contacts[0]?.email` (if present), role[2+] → left as-is (role label string). Happens server-side during document creation. +- D-07: Every `SignatureFieldData` object copied from template gets `id: crypto.randomUUID()`. No template field ID may appear in the new document's fields. +- D-08: Created document's `formTemplateId` set to the template's `formTemplateId`. Document is NOT linked to `documentTemplate` record after creation — snapshot, not live reference. + +**Text Hints as Quick-Fill** +- D-09: Field `hint` values copied verbatim from `documentTemplates.signatureFields[].hint` into new document's fields. +- D-10: In PreparePanel, when agent selects a text field with a `hint`, it appears as a clickable suggestion chip in the existing Quick Fill suggestions area. Clicking fills the field with hint text. +- D-11: Hints are NOT shown as placeholder text on the field — only in PreparePanel suggestions area. +- D-12: PreparePanel already has `textFillData` system and Quick Fill area. Hint chip is added to that same section — no new UI region. + +**Navigation (Already Delivered — No Work Needed)** +- Templates nav link: DONE in Phase 19 +- `/portal/templates` list page: DONE in Phase 19 +- Phase 20 does NOT touch nav or templates list page. + +### Claude's Discretion +- Exact tab styling in AddDocumentModal (pill tabs vs underline tabs) +- Empty state for "My Templates" tab when no templates exist +- How field count is displayed in the template picker row +- Order of roles when more than 2 are present in the template + +### Deferred Ideas (OUT OF SCOPE) +- Template versioning (already addressed by D-08 snapshot approach) +- Bulk-apply template to multiple clients at once +- Template categories or tags for organizing the My Templates list + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| TMPL-10 | Agent can choose "Start from template" when adding a document | D-01/D-02: "My Templates" tab in AddDocumentModal; API in AddDocumentModal.handleSubmit | +| TMPL-11 | Applying template creates document with all template fields pre-loaded (fresh IDs, positions, types, role assignments) | D-05/D-07: POST /api/documents copies `signatureFields` with `crypto.randomUUID()` per field | +| TMPL-12 | Template signer roles auto-mapped to client contacts in order; agent can override before sending | D-06: server maps roles to `client.email` + `contacts[]`; PreparePanel signer list is the override surface | +| TMPL-13 | Text hints from template appear as quick-fill suggestions in PreparePanel | D-09/D-10/D-12: hints copied into document fields; PreparePanel surfaces as chip next to Client Name/Address | +| TMPL-14 | Editing a template after documents created from it does NOT change those documents | D-08: snapshot copy at creation — no FK link from document back to documentTemplate | +| TMPL-15 | "Templates" in portal nav | Already done Phase 19 — no work needed | +| TMPL-16 | Templates list page shows all templates with form name, field count, last-updated date | Already done Phase 19 — no work needed | + + +--- + +## Summary + +Phase 20 is the final wiring of the template system into the document creation flow. The infrastructure is fully in place (Phase 18: schema + API, Phase 19: editor + field saving). This phase adds three incremental features: a second tab in `AddDocumentModal`, an extension to `POST /api/documents`, and a hint chip in `PreparePanel`. + +The code surface is small and well-bounded. All state management patterns (tabs as `useState`, Quick Fill chips, selectedFieldId threading) are established. The only new data flow is `documentTemplateId` on the POST body causing the server to fetch the template, copy fields with fresh UUIDs, and set `signers` from role-to-email mapping. + +**TMPL-14 and TMPL-15/16 are already satisfied.** TMPL-14 is satisfied by the snapshot design (D-08) — no new code. TMPL-15 and TMPL-16 were completed in Phase 19 and need no changes. + +**Primary recommendation:** Three focused changes — AddDocumentModal tab UI, POST /api/documents template branch, PreparePanel hint chip — each independent and non-breaking. + +--- + +## Standard Stack + +### Core (already installed — no new dependencies) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| React `useState` | (React 19) | Tab switching, template selection state | Existing modal pattern | +| Next.js App Router `fetch` | (Next.js 16.2) | `GET /api/templates` from modal | Existing pattern in AddDocumentModal | +| Drizzle ORM | (existing) | DB read of `documentTemplates` + `clients.contacts` in POST handler | All DB calls use Drizzle in this project | +| `crypto.randomUUID()` | Node built-in | Fresh UUID per copied field | Already used in `documents` route | + +### No new packages required + +The phase requires zero new npm dependencies. All libraries are already installed and in use. + +--- + +## Architecture Patterns + +### Pattern 1: Two-Tab Modal (additive to AddDocumentModal) + +**What:** Add a `activeTab: 'forms' | 'templates'` state variable. Render tab buttons at modal top. Conditionally render either the existing forms list (completely unchanged) or the new My Templates list. All existing submit logic is preserved — only the selection state changes. + +**Key insight from reading the code:** `AddDocumentModal` currently has two submission paths (custom file vs form template), determined by which of `selectedTemplate` (form library) or `customFile` is set. The template tab adds a third selection type: `selectedDocumentTemplate`. When `selectedDocumentTemplate` is set, the JSON POST branch sends `documentTemplateId` instead of `formTemplateId`. + +**State shape needed:** +```typescript +// New state +const [activeTab, setActiveTab] = useState<'forms' | 'templates'>('forms'); +const [documentTemplates, setDocumentTemplates] = useState([]); +const [selectedDocumentTemplate, setSelectedDocumentTemplate] = useState(null); + +// DocumentTemplateRow type (from GET /api/templates response) +type DocumentTemplateRow = { + id: string; + name: string; + formName: string | null; + fieldCount: number; + updatedAt: string; +}; +``` + +**Fetch:** The `GET /api/templates` endpoint already returns `id, name, formName, fieldCount, updatedAt`. The modal can call it once on mount (alongside the existing forms-library fetch) or lazily on first tab click. Lazy on first click avoids an unnecessary request when agent never uses My Templates. + +**Submit path for template:** +```typescript +// Inside handleSubmit — new third branch: +if (selectedDocumentTemplate) { + await fetch('/api/documents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientId, + name: docName.trim(), + documentTemplateId: selectedDocumentTemplate.id, + }), + }); +} +``` + +**Validation:** The submit button's disabled condition must include the new path: +``` +disabled={saving || (!selectedTemplate && !customFile && !selectedDocumentTemplate) || !docName.trim()} +``` + +**Selection mutual exclusivity:** When agent picks a form-library template, clear `selectedDocumentTemplate`. When agent picks a document template, clear `selectedTemplate` and `customFile`. Tab switching itself does NOT clear selections — so switching back preserves prior selection. + +**ClientId availability:** `AddDocumentModal` receives `clientId` as a prop. The `client` object itself (with `contacts`) is NOT passed into `AddDocumentModal` — it stays in `ClientProfileClient`. The role-to-email mapping is done server-side in `POST /api/documents` by fetching the client record from DB. + +### Pattern 2: POST /api/documents — Template Branch + +**What:** Extend the existing JSON-body path in `POST /api/documents` to handle `documentTemplateId`. This is a new branch inside the existing `else` block (JSON content-type). + +**Existing flow (JSON path):** reads `formTemplateId`, looks up `formTemplates` to get `filename`, copies PDF file, inserts document with `signatureFields: null`. + +**New flow (template path):** +1. Parse `documentTemplateId` from body alongside existing fields +2. When `documentTemplateId` is present: + a. Fetch `documentTemplates` record with `formTemplateId` (to get `filename` from joined `formTemplates`) and `signatureFields` + b. Copy PDF from seeds dir using the form template's `filename` (same as existing path) + c. Copy `signatureFields` with fresh UUIDs: `template.signatureFields.map(f => ({ ...f, id: crypto.randomUUID() }))` + d. Extract unique role labels (order of first appearance) from copied fields' `signerEmail` values + e. Build `signers` array for the document: `roles[0] → client.email`, `roles[1] → contacts[0]?.email ?? roles[1]`, `roles[2+] → roles[n]` (kept as-is) + f. Fetch client record to get `email` and `contacts` + g. Set `formTemplateId` from the document template's `formTemplateId` + h. Insert document with `signatureFields: copiedFields`, `signers: mappedSigners`, `formTemplateId` + +**Critical: what "roles" means.** Template fields carry role labels (e.g., "Buyer", "Seller") in their `signerEmail` slot. These are NOT real emails. The role-to-email mapping replaces these strings with real emails in the `signers` array on the document record. The `signatureFields` themselves keep the role labels in `signerEmail` — the `signers` array is where real emails live. Verify this matches Phase 16's PreparePanel signer resolution pattern. + +**Client fetch:** The `POST /api/documents` handler currently does NOT fetch the client record. Adding `documentTemplateId` support requires a new DB query: `db.query.clients.findFirst({ where: eq(clients.id, clientId) })`. + +**Field ID guarantee:** `crypto.randomUUID()` is Node built-in — already imported implicitly (used on `documents.id` via Drizzle schema defaults). Explicitly call it per field in the map. + +**Code structure (pseudo):** +```typescript +// New branch inside the JSON else block: +let signatureFields: SignatureFieldData[] | null = null; +let mappedSigners: DocumentSigner[] | null = null; + +if (documentTemplateId) { + const docTemplate = await db.query.documentTemplates.findFirst({ + where: eq(documentTemplates.id, documentTemplateId), + with: { formTemplate: true }, + }); + if (!docTemplate) return Response.json({ error: 'Document template not found' }, { status: 404 }); + + // Copy PDF using the associated form template's filename + const srcPath = path.join(SEEDS_DIR, docTemplate.formTemplate.filename); + await copyFile(srcPath, destPath); + + // Copy fields with fresh UUIDs — hints are preserved verbatim (D-09) + const rawFields = docTemplate.signatureFields ?? []; + signatureFields = rawFields.map(f => ({ ...f, id: crypto.randomUUID() })); + + // Build signers from role-to-email mapping (D-06) + const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) }); + const uniqueRoles = [...new Set( + signatureFields.map(f => f.signerEmail).filter(Boolean) + )] as string[]; + const contacts = [client?.email, ...(client?.contacts ?? []).map(c => c.email)]; + mappedSigners = uniqueRoles.map((role, i) => ({ + email: contacts[i] ?? role, // fallback to role label if no contact at that index + color: SIGNER_COLORS[i % SIGNER_COLORS.length], + })); + + formTemplateId = docTemplate.formTemplateId; // override for DB insert +} else if (formTemplateId) { + // existing form-library path unchanged +} +``` + +**SIGNER_COLORS in API route:** `PreparePanel` and `DocumentPageClient` both define `SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b']`. The API route needs the same palette to assign consistent colors. Define the same constant in the route or extract to a shared util. Recommendation: inline the constant in the route (same value, avoids client/server boundary import complexity). + +### Pattern 3: PreparePanel Hint Chip + +**What:** When `selectedFieldId` is set, look up the field's `hint` in the fields list. If present, render a chip in the Quick Fill section alongside existing suggestions. + +**Data access challenge:** PreparePanel currently receives `selectedFieldId` and `textFillData` but does NOT receive the list of fields — it doesn't know the hints. There are two approaches: + +**Option A (server-threaded prop):** Pass `fields: SignatureFieldData[]` from `DocumentPageClient` to `PreparePanel` as a new optional prop. `DocumentPageClient` would need to track fields state. But currently fields are fetched and owned by `FieldPlacer` — `DocumentPageClient` doesn't hold them. + +**Option B (hint-only prop, preferred):** Pass `fieldHints: Record` from `DocumentPageClient` to `PreparePanel`. `DocumentPageClient` initializes `fieldHints` when it has the hint data. But `DocumentPageClient` also doesn't receive fields on mount — they're fetched by FieldPlacer. + +**Option C (fetch in PreparePanel, simplest):** When `selectedFieldId` changes (and is non-null), PreparePanel fetches the hint for that field from `/api/documents/:id/fields`, picks out the selected field's `hint`. This adds a round-trip but is completely self-contained. However it fetches ALL fields just for the hint. + +**Option D (lift fields to DocumentPageClient via callback, recommended):** Introduce a `onFieldsLoaded` callback from `DocumentPageClient` → `FieldPlacer`. When FieldPlacer fetches fields from DB, it also calls this callback. `DocumentPageClient` stores `fields: SignatureFieldData[]` in state and passes them to `PreparePanel`. This is analogous to how `aiPlacementKey` causes FieldPlacer to re-fetch. However this requires more prop threading. + +**Simplest viable approach for this phase (recommended based on scope):** Pass `selectedFieldHint: string | undefined` as a new prop to `PreparePanel`. In `DocumentPageClient`, add a `fields` state initialized from a one-time fetch of `/api/documents/:docId/fields` on mount. When `selectedFieldId` changes, look up the hint from `fields`. This is one additional fetch (which is already done by FieldPlacer anyway — lightweight deduplication). + +Actually — the simplest correct approach: `DocumentPageClient` fetches fields once at mount to populate `fields` state. This is also needed for the role auto-seed check (`initialSigners.length === 0` check already there). The hint lookup is then: `const selectedFieldHint = selectedFieldId ? fields.find(f => f.id === selectedFieldId)?.hint : undefined`. + +Pass `selectedFieldHint?: string` as a new optional prop to `PreparePanel`. In PreparePanel's Quick Fill section: +```typescript +{selectedFieldHint && ( + +)} +``` + +This is a backward-compatible optional prop (default undefined). No existing call sites break. + +**Where to fetch fields in DocumentPageClient:** Add a `useEffect` on mount (empty deps) that calls `GET /api/documents/:docId/fields` and stores in `useState([])`. This endpoint already exists (used by PreparePanel's handlePrepare). When `aiPlacementKey` changes (AI re-places), re-fetch to pick up new hints. + +### Pattern 4: GET /api/templates — Already Sufficient + +The existing endpoint already returns all required data: +- `id`, `name`, `formName`, `fieldCount`, `updatedAt` — everything the My Templates tab needs. +- It does NOT return `signatureFields` in the response (the `signatureFields` column is fetched but only used server-side to compute `fieldCount`). This is correct per D-02 — the modal only needs name/formName/fieldCount. +- The server-side POST handler fetches `signatureFields` directly from DB, so no change to GET response needed. + +**Confidence:** HIGH — confirmed by reading `src/app/api/templates/route.ts` lines 1-36. + +### Recommended Project Structure (files touched) + +``` +teressa-copeland-homes/src/ +├── app/ +│ ├── portal/ +│ │ └── _components/ +│ │ └── AddDocumentModal.tsx ← Add My Templates tab + submit branch +│ └── api/ +│ └── documents/ +│ └── route.ts ← Add documentTemplateId branch +└── app/portal/(protected)/documents/[docId]/ + └── _components/ + ├── DocumentPageClient.tsx ← Add fields state + selectedFieldHint derivation + └── PreparePanel.tsx ← Add selectedFieldHint prop + chip render +``` + +### Anti-Patterns to Avoid + +- **Mutating template field IDs in place:** Always create new objects (`{ ...f, id: crypto.randomUUID() }`) — never mutate the JSONB objects fetched from DB. +- **Setting signerEmail on signers array entries from role labels:** The `signers` array should always contain real emails (or the role label as fallback if no contact mapped). Fields' `signerEmail` slots keep the role labels — this is intentional for the existing color-assignment system. +- **Fetching fields inside PreparePanel on every selectedFieldId change:** One fetch on mount (or on aiPlacementKey change) is sufficient. Don't fetch inside PreparePanel — keep it prop-driven. +- **Breaking the existing forms-library tab:** D-04 is explicit — zero changes to existing behavior. Tab switching must not clear existing form/file selections unless the user actively picks something in the other tab. +- **Passing `signatureFields` in GET /api/templates response:** The full JSONB column can be large (100+ fields). The modal only needs `id`, `name`, `formName`, `fieldCount`. Don't add `signatureFields` to the GET response. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| UUID generation per field | Custom ID generator | `crypto.randomUUID()` | Already used in schema defaults; Node built-in; no import needed | +| Role deduplication in order | Custom Set logic | `[...new Set(array)]` | Standard JS, preserves insertion order | +| Tab UI | Custom tab library | `useState` + conditional render | Project uses zero UI component libraries — plain Tailwind/inline styles | +| Field lookup by ID | Custom index | `Array.prototype.find` | Fields array is small (< 200 items); no index needed | + +--- + +## Runtime State Inventory + +This phase is not a rename/refactor. No runtime state inventory needed. All changes are code-only with no stored strings being renamed. + +--- + +## Common Pitfalls + +### Pitfall 1: signerEmail slot carries role labels, not emails + +**What goes wrong:** Developer reads `field.signerEmail` expecting an email address, but in templates it contains "Buyer", "Seller", etc. Code that calls `isValidEmail(field.signerEmail)` will fail. The `getSignerEmail()` utility will return the role label, which is not a real email. + +**Why it happens:** Template fields use `signerEmail` as a role-label slot (v1.3 design decision). The field type `SignatureFieldData.signerEmail` is typed as `string | undefined` with no discrimination between role labels and real emails. + +**How to avoid:** In the POST /api/documents template branch, treat `field.signerEmail` values as role labels. The `signers` array on the document is where real emails go. Never pass template field `signerEmail` values directly to email-sending logic. + +**Warning signs:** `isValidEmail(field.signerEmail)` returning false in the POST handler. Email send step receiving strings like "Buyer" or "Seller". + +### Pitfall 2: Field copy shares object references (JSONB mutation) + +**What goes wrong:** `const copiedFields = template.signatureFields.map(f => ({ ...f, id: crypto.randomUUID() }))` — shallow spread is sufficient because `SignatureFieldData` has only primitive properties (strings, numbers). No nested objects. But if a future field type adds nested JSONB, mutation could propagate to the template record. + +**Why it happens:** Drizzle returns JSONB as JS objects, not frozen values. + +**How to avoid:** The shallow spread `{ ...f }` is correct for the current `SignatureFieldData` shape. Document this assumption with a comment in the code. + +### Pitfall 3: clientId not validated before client fetch + +**What goes wrong:** The POST handler fetches the client for role mapping but doesn't guard against an invalid/missing `clientId`. If the client doesn't exist, `db.query.clients.findFirst` returns `undefined` and `client?.email` is undefined, so `contacts[0]` becomes `undefined`. + +**Why it happens:** The existing POST handler validates `clientId` and `name` are non-empty strings but does not verify the client exists in DB. + +**How to avoid:** Add a client existence check before the template branch: `if (!clientId || !name) return 400`. The role mapping degrades gracefully (role labels used as fallback `email: contacts[i] ?? role`) — so even if client fetch fails, the insert doesn't crash. But a missing client means the document is orphaned, which is a separate concern the existing code already has. + +### Pitfall 4: "My Templates" tab fetch race with modal open + +**What goes wrong:** Modal opens → fetch to `/api/templates` is fired → agent has no templates → fetch returns empty array → agent is confused by empty state. + +**Why it happens:** Network latency on modal open. + +**How to avoid:** Show a loading state or "No templates yet" empty state. The empty state copy should be helpful: "No templates saved yet. Create one from the Templates page." + +### Pitfall 5: SIGNER_COLORS constant duplication + +**What goes wrong:** `PreparePanel`, `DocumentPageClient`, and now `POST /api/documents` all define the same `SIGNER_COLORS` array. If the palette is changed in one place, the others become inconsistent. + +**Why it happens:** The color array originated in `PreparePanel`, was duplicated in `DocumentPageClient` (Phase 16), and now needs to be in the API route. + +**How to avoid:** For this phase, inline the constant in the API route as-is (same 4-color array). The duplication is an existing project pattern — Phase 20 should not introduce a shared-util refactor that changes scope. Document the duplication in a comment for future cleanup. + +--- + +## Code Examples + +### Apply template — field copy with fresh UUIDs + +```typescript +// Source: CONTEXT.md D-07, confirmed against schema.ts SignatureFieldData shape +const rawFields: SignatureFieldData[] = docTemplate.signatureFields ?? []; +const copiedFields: SignatureFieldData[] = rawFields.map(f => ({ + ...f, // copies: page, x, y, width, height, type, hint, signerEmail (role label) + id: crypto.randomUUID(), // fresh UUID — template field ID must never appear in documents (D-07) +})); +``` + +### Role-to-email mapping + +```typescript +// Source: CONTEXT.md D-06 + specifics pseudocode +// Collect unique role labels in order of first appearance +const seenRoles = new Set(); +const uniqueRoles: string[] = []; +for (const f of copiedFields) { + if (f.signerEmail && !seenRoles.has(f.signerEmail)) { + seenRoles.add(f.signerEmail); + uniqueRoles.push(f.signerEmail); + } +} + +// Build client contacts array: primary email first, then co-buyer emails +const clientEmails = [ + client?.email, + ...(client?.contacts ?? []).map((c: ClientContact) => c.email), +].filter(Boolean) as string[]; + +const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b']; +const mappedSigners: DocumentSigner[] = uniqueRoles.map((role, i) => ({ + email: clientEmails[i] ?? role, // fallback: keep role label if no client contact at index i + color: SIGNER_COLORS[i % SIGNER_COLORS.length], +})); +``` + +### PreparePanel hint chip (additive) + +```typescript +// Source: PreparePanel.tsx Quick Fill section pattern (lines 317-355) +// New prop added to PreparePanelProps: +// selectedFieldHint?: string; + +// Inside the Quick Fill section, after existing chips: +{selectedFieldHint && selectedFieldId && ( + +)} +``` + +### DocumentPageClient — fields state + hint derivation + +```typescript +// New state in DocumentPageClient +const [fields, setFields] = useState([]); + +// Fetch on mount and on AI placement key change +useEffect(() => { + fetch(`/api/documents/${docId}/fields`) + .then(r => r.json()) + .then((data: SignatureFieldData[]) => setFields(data)) + .catch(() => {}); +}, [docId, aiPlacementKey]); + +// Derive hint for selected field +const selectedFieldHint = selectedFieldId + ? fields.find(f => f.id === selectedFieldId)?.hint + : undefined; + +// Pass to PreparePanel: +// selectedFieldHint={selectedFieldHint} +``` + +### AddDocumentModal — tab state and My Templates fetch + +```typescript +// New state +const [activeTab, setActiveTab] = useState<'forms' | 'templates'>('forms'); +const [docTemplates, setDocTemplates] = useState([]); +const [docTemplatesLoaded, setDocTemplatesLoaded] = useState(false); +const [selectedDocTemplate, setSelectedDocTemplate] = useState(null); + +// Lazy fetch on first tab click (not on mount, to avoid unnecessary request) +function handleSwitchToTemplates() { + setActiveTab('templates'); + if (!docTemplatesLoaded) { + fetch('/api/templates') + .then(r => r.json()) + .then((data: DocumentTemplateRow[]) => { setDocTemplates(data); setDocTemplatesLoaded(true); }) + .catch(console.error); + } +} + +// In handleSelectTemplate (existing): also clear selectedDocTemplate +// In template row click: clear selectedTemplate, setCustomFile(null), set selectedDocTemplate +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Single tab in AddDocumentModal | Two tabs: Forms Library + My Templates | Phase 20 | Additive — forms tab unchanged | +| POST /api/documents: form-library or custom-file only | Three paths: form-library, custom-file, document-template | Phase 20 | New branch with snapshot copy | +| PreparePanel Quick Fill: 3 chips only (Name, Address, Email) | Quick Fill: up to 4 chips (+ Template Hint when applicable) | Phase 20 | Optional — only shown when selectedField has a hint | + +--- + +## Environment Availability + +Step 2.6: SKIPPED — no external tools or services beyond the existing Next.js stack. All changes are to API routes and React components running in the existing Docker/Node.js environment. + +--- + +## Validation Architecture + +**nyquist_validation:** Not explicitly set to `false` in config.json — treated as enabled. + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Jest 29.7 + ts-jest | +| Config file | `package.json` (jest.preset: ts-jest, testEnvironment: node) | +| Quick run command | `cd teressa-copeland-homes && npx jest --testPathPattern=prepare-document` | +| Full suite command | `cd teressa-copeland-homes && npx jest` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| TMPL-10 | "My Templates" tab appears, fetches templates | manual-only (React UI) | n/a — no component test runner | ❌ not applicable | +| TMPL-11 | Fields copied with fresh UUIDs, positions/types preserved | unit | `npx jest --testPathPattern=apply-template` | ❌ Wave 0 | +| TMPL-12 | Role-to-email mapping logic | unit | `npx jest --testPathPattern=apply-template` | ❌ Wave 0 | +| TMPL-13 | Hint chip appears when selected field has hint | manual-only (React UI) | n/a | ❌ not applicable | +| TMPL-14 | Snapshot independence (no live link) | architectural (no code) | n/a — satisfied by D-08 design | n/a | +| TMPL-15 | Templates nav link | already complete (Phase 19) | n/a | n/a | +| TMPL-16 | Templates list page | already complete (Phase 19) | n/a | n/a | + +### Sampling Rate + +- **Per task commit:** `cd teressa-copeland-homes && npx jest` (full suite — only 1 test file currently) +- **Per wave merge:** `cd teressa-copeland-homes && npx jest` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `src/lib/utils/__tests__/apply-template.test.ts` — unit tests for field copy (UUID freshness) and role-to-email mapping logic. These are pure functions that can be extracted from the POST handler and tested in isolation. + +*(If extracting to a util is too heavy for this phase, the test can inline the logic and test it directly — following the `prepare-document.test.ts` pattern of testing the pure conversion math.)* + +--- + +## Open Questions + +1. **Does `/api/documents/:docId/fields` endpoint exist?** + - What we know: `PreparePanel.handlePrepare` calls `fetch(`/api/documents/${docId}/fields`)` (line 262) — so the endpoint must exist. + - What's unclear: Not verified by reading the route file. + - Recommendation: Confirm endpoint exists before DocumentPageClient plan relies on it. Low risk — PreparePanel already uses it. + +2. **Where does `SignatureFieldData.hint` appear on the signing page for client-text fields?** + - What we know: TMPL-08 (Phase 19) added hint support to template editor. D-11 says hints are NOT shown as placeholder on the field. + - What's unclear: Whether the signing page currently renders hints as placeholder text for `client-text` fields — if so, hints will appear there in documents created from templates (which is probably correct behavior, just not PreparePanel's concern). + - Recommendation: Out of scope for Phase 20 planning — PreparePanel's D-11 constraint is clear. Note for future reference. + +3. **What happens when a document template has zero signatureFields (null/empty)?** + - What we know: `documentTemplates.signatureFields` is nullable JSONB (Phase 18 decision: "template starts empty"). An agent could create a template but never open the editor. + - What's unclear: Should applying an empty template be blocked or allowed? + - Recommendation: Allow it — `copiedFields = []` is valid. Document is created with no fields (agent places them manually). This matches the existing "create document with no fields" flow. + +--- + +## Sources + +### Primary (HIGH confidence) + +- Direct reading of `src/app/portal/_components/AddDocumentModal.tsx` — existing modal structure, state patterns, submit branches +- Direct reading of `src/app/api/documents/route.ts` — existing POST handler, DB insert pattern +- Direct reading of `src/lib/db/schema.ts` — `documentTemplates`, `SignatureFieldData`, `ClientContact`, `DocumentSigner` types +- Direct reading of `src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` — Quick Fill section implementation +- Direct reading of `src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx` — state threading pattern, signers, aiPlacementKey +- Direct reading of `src/app/api/templates/route.ts` — GET response shape, JOIN with formTemplates +- Direct reading of `src/app/portal/_components/ClientProfileClient.tsx` — how AddDocumentModal is called (clientId only, no client object) +- Direct reading of `.planning/phases/20-apply-template-and-portal-nav/20-CONTEXT.md` — locked decisions D-01 through D-12 +- Direct reading of `.planning/STATE.md` — accumulated pre-decisions, Phase 19 confirmation +- Direct reading of `package.json` jest config — test framework details + +### Secondary (MEDIUM confidence) + +- None needed — all findings are from direct source code inspection. + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — zero new dependencies; all libraries confirmed in package.json and in active use +- Architecture: HIGH — all patterns confirmed by reading actual source files +- Pitfalls: HIGH — derived from reading the exact code, not speculation +- Test infrastructure: HIGH — confirmed from package.json and existing test file + +**Research date:** 2026-04-06 +**Valid until:** 2026-05-06 (stable codebase — no fast-moving dependencies) + +## Project Constraints (from CLAUDE.md) + +The following directives from `teressa-copeland-homes/CLAUDE.md` are binding on the planner: + +| Directive | Implication for Phase 20 | +|-----------|--------------------------| +| Extend existing patterns unless explicitly justified | AddDocumentModal gets additive tabs; PreparePanel gets additive optional prop; POST handler gets additive branch. No new abstractions. | +| No greenfield assumptions — inspect repository first | Done: all 7 canonical files read and understood. | +| Consistency is mandatory (file structure, naming, patterns) | Tab UI uses plain `useState` + Tailwind, not a component library. Fetch pattern matches existing `useEffect` + `fetch`. | +| All solutions must reference real repo structure | Research is grounded in actual file contents. | +| Security: path traversal guard | The PDF copy path in the template branch must pass through the same `!destPath.startsWith(UPLOADS_DIR)` guard as existing paths. | +| Deviation must be justified | No deviations proposed. |