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