34 KiB
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>
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/documentsaccepts a new optional body fielddocumentTemplateId: string. When present, server reads template'ssignatureFields, copies with new UUIDs, stores as new document's fields. - D-06: Role-to-email mapping: server reads role labels from copied fields'
signerEmailvalues. Buildssignersarray: 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
SignatureFieldDataobject copied from template getsid: crypto.randomUUID(). No template field ID may appear in the new document's fields. - D-08: Created document's
formTemplateIdset to the template'sformTemplateId. Document is NOT linked todocumentTemplaterecord after creation — snapshot, not live reference.
Text Hints as Quick-Fill
- D-09: Field
hintvalues copied verbatim fromdocumentTemplates.signatureFields[].hintinto 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
textFillDatasystem 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/templateslist 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 </user_constraints>
<phase_requirements>
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 |
| </phase_requirements> |
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:
// New state
const [activeTab, setActiveTab] = useState<'forms' | 'templates'>('forms');
const [documentTemplates, setDocumentTemplates] = useState<DocumentTemplateRow[]>([]);
const [selectedDocumentTemplate, setSelectedDocumentTemplate] = useState<DocumentTemplateRow | null>(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:
// 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):
- Parse
documentTemplateIdfrom body alongside existing fields - When
documentTemplateIdis present: a. FetchdocumentTemplatesrecord withformTemplateId(to getfilenamefrom joinedformTemplates) andsignatureFieldsb. Copy PDF from seeds dir using the form template'sfilename(same as existing path) c. CopysignatureFieldswith fresh UUIDs:template.signatureFields.map(f => ({ ...f, id: crypto.randomUUID() }))d. Extract unique role labels (order of first appearance) from copied fields'signerEmailvalues e. Buildsignersarray 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 getemailandcontactsg. SetformTemplateIdfrom the document template'sformTemplateIdh. Insert document withsignatureFields: 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):
// 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<string, string> 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:
{selectedFieldHint && (
<button
type="button"
onClick={() => onQuickFill(selectedFieldId!, selectedFieldHint)}
className="w-full text-left px-3 py-2 text-sm border rounded bg-white hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
<span className="text-xs text-gray-400 block">Template Hint</span>
<span className="truncate block">{selectedFieldHint}</span>
</button>
)}
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<SignatureFieldData[]>([]). 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
signatureFieldsin the response (thesignatureFieldscolumn is fetched but only used server-side to computefieldCount). This is correct per D-02 — the modal only needs name/formName/fieldCount. - The server-side POST handler fetches
signatureFieldsdirectly 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
signersarray should always contain real emails (or the role label as fallback if no contact mapped). Fields'signerEmailslots 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
signatureFieldsin GET /api/templates response: The full JSONB column can be large (100+ fields). The modal only needsid,name,formName,fieldCount. Don't addsignatureFieldsto 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
// 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
// Source: CONTEXT.md D-06 + specifics pseudocode
// Collect unique role labels in order of first appearance
const seenRoles = new Set<string>();
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)
// 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 && (
<button
type="button"
onClick={() => onQuickFill(selectedFieldId, selectedFieldHint)}
className="w-full text-left px-3 py-2 text-sm border rounded bg-white hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
<span className="text-xs text-gray-400 block">Template Hint</span>
<span className="truncate block">{selectedFieldHint}</span>
</button>
)}
DocumentPageClient — fields state + hint derivation
// New state in DocumentPageClient
const [fields, setFields] = useState<SignatureFieldData[]>([]);
// 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
// New state
const [activeTab, setActiveTab] = useState<'forms' | 'templates'>('forms');
const [docTemplates, setDocTemplates] = useState<DocumentTemplateRow[]>([]);
const [docTemplatesLoaded, setDocTemplatesLoaded] = useState(false);
const [selectedDocTemplate, setSelectedDocTemplate] = useState<DocumentTemplateRow | null>(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
-
Does
/api/documents/:docId/fieldsendpoint exist?- What we know:
PreparePanel.handlePreparecallsfetch(/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.
- What we know:
-
Where does
SignatureFieldData.hintappear 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-textfields — 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.
-
What happens when a document template has zero signatureFields (null/empty)?
- What we know:
documentTemplates.signatureFieldsis 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.
- What we know:
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,DocumentSignertypes - 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.jsonjest 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. |