docs(phase-20): research apply-template-and-portal-nav phase
This commit is contained in:
573
.planning/phases/20-apply-template-and-portal-nav/20-RESEARCH.md
Normal file
573
.planning/phases/20-apply-template-and-portal-nav/20-RESEARCH.md
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
# Phase 20: Apply Template and Portal Nav - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-06
|
||||||
|
**Domain:** Next.js App Router, Drizzle ORM, React client-state wiring, JSONB field snapshot copy
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## 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:**
|
||||||
|
```typescript
|
||||||
|
// 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:**
|
||||||
|
```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<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:
|
||||||
|
```typescript
|
||||||
|
{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.
|
||||||
|
|
||||||
|
### 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<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)
|
||||||
|
|
||||||
|
```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 && (
|
||||||
|
<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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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.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. |
|
||||||
Reference in New Issue
Block a user