Files
2026-04-06 14:05:44 -06:00

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

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

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

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

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

  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.tsdocumentTemplates, 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.