Files

332 lines
15 KiB
Markdown
Raw Permalink Normal View History

2026-04-06 11:54:40 -06:00
# Architecture Patterns: Document Templates
**Domain:** Real-estate agent portal — PDF signing with field placement
**Researched:** 2026-04-06
**Confidence:** HIGH (based on direct code inspection)
---
## Context: What Was Inspected
| File | Purpose |
|------|---------|
| `src/lib/db/schema.ts` | Full Drizzle schema — all tables, types, helpers |
| `src/app/api/documents/route.ts` | Document creation (two paths: library copy + custom upload) |
| `src/app/api/documents/[id]/fields/route.ts` | GET/PUT for `signatureFields` JSONB |
| `src/app/api/forms-library/route.ts` | Lists `form_templates` rows |
| `documents/[docId]/_components/DocumentPageClient.tsx` | State owner for document editing page |
| `documents/[docId]/_components/FieldPlacer.tsx` | Drag-drop field placement (822 lines) |
| `documents/[docId]/_components/PreparePanel.tsx` | Prepare + send side panel |
| `portal/_components/AddDocumentModal.tsx` | "Add Document" modal — uses form_templates |
| `portal/_components/ClientProfileClient.tsx` | Client profile page with document list |
| `drizzle/` | 12 migration files (00000011) |
---
## Current Architecture
### Schema Summary
```
form_templates (id, name, filename, createdAt, updatedAt)
— raw PDF registry only, no field data
documents (id, name, clientId, status, formTemplateId FK,
filePath, signatureFields JSONB, textFillData JSONB,
signers JSONB, preparedFilePath, signedFilePath,
emailAddresses, pdfHash, sentAt, signedAt,
completionTriggeredAt, assignedClientId)
— per-client working copy, carries all field placement state
clients (id, name, email, contacts JSONB, propertyAddress,
createdAt, updatedAt)
signing_tokens (jti, documentId FK, signerEmail, expiresAt, usedAt)
audit_events (id, documentId FK, eventType, metadata JSONB, ...)
users (id, email, passwordHash, agentSignatureData,
agentInitialsData)
```
### Document Creation Flow (current)
```
AddDocumentModal
→ POST /api/documents
├── (library path) formTemplateId provided
│ → copy seed PDF to uploads/clients/{clientId}/{docId}.pdf
│ → INSERT documents (no signatureFields — empty, to be placed)
└── (upload path) custom file
→ write file to uploads/clients/{clientId}/{docId}.pdf
→ INSERT documents (no signatureFields)
```
After creation, agent opens `/portal/documents/{docId}`, uses FieldPlacer to place fields
(drag-drop or AI auto-place), then uses PreparePanel to fill text values and send.
### FieldPlacer Integration Point
`FieldPlacer` is tightly coupled to `docId`:
- Fetches fields from `GET /api/documents/{docId}/fields`
- Persists changes to `PUT /api/documents/{docId}/fields`
- The `aiPlacementKey` prop triggers a re-fetch after AI auto-place
The component has **no concept of a "template source"** — it only reads/writes document fields.
That is the integration seam where template pre-loading must be injected.
---
## Schema Decision: New Table vs Extend `form_templates`
**Decision: New `document_templates` table. Do not extend `form_templates`.**
### Reasoning
`form_templates` is currently a **file registry** — a pointer to a PDF filename on disk.
Every part of the system treats it that way:
- `forms-library/route.ts` returns `{ id, name, filename }` only
- `documents/route.ts` uses it only to resolve `template.filename` for `copyFile()`
- `AddDocumentModal` renders it as a name list for file selection
- `documents.formTemplateId` is a FK used only for audit/traceability
Adding `signatureFields` + `signers` JSONB to `form_templates` would:
1. Break the clear single-responsibility of that table
2. Force every consumer that reads `form_templates` to handle nullable field data
3. Make the "template has fields" state conflated with "template is just a PDF"
4. Create confusion: which `form_templates` rows have fields? All? Some?
A separate `document_templates` table is clean:
- Represents an intentionally-prepared template (PDF + fields + signer role slots)
- Can be soft-deleted, versioned, or duplicated without touching `form_templates`
- `form_templates` continues working exactly as it does today
- The new table has an optional FK to `form_templates` for traceability
### Proposed Schema Addition
```typescript
// New interface — signer role slot (no email yet, filled at document creation)
export interface TemplateSignerSlot {
role: string; // e.g. "Buyer", "Seller", "Agent"
color: string; // display color, matches FieldPlacer SIGNER_COLORS
}
// SignatureFieldData already has signerEmail — for templates, signerEmail
// holds the ROLE string (e.g. "Seller") rather than an actual email.
// This is a deliberate dual-use: roles are resolved to emails at apply time.
export const documentTemplates = pgTable("document_templates", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
// Optional link back to the form_templates raw PDF
formTemplateId: text("form_template_id").references(() => formTemplates.id),
// Stored field layout — same shape as documents.signatureFields
// signerEmail values are ROLE strings at this stage
signatureFields: jsonb("signature_fields").$type<SignatureFieldData[]>(),
// Signer role slots — defines which roles exist and their colors
signerSlots: jsonb("signer_slots").$type<TemplateSignerSlot[]>(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
**Backwards compatibility:** Zero impact on existing documents. `form_templates` is unchanged.
All existing documents with `signatureFields: null` continue working (FieldPlacer handles empty arrays).
---
## Component Boundaries
### New Components
| Component | Location | Responsibility |
|-----------|----------|---------------|
| `TemplatesPageClient` | `portal/_components/` | List templates, launch editor, delete |
| `TemplateEditorPage` (server) | `portal/(protected)/templates/[templateId]/page.tsx` | Loads template data, renders editor shell |
| `TemplateEditorClient` | `portal/(protected)/templates/[templateId]/_components/` | Thin wrapper — composes FieldPlacer + TemplatePanel |
| `TemplatePanel` | same directory | Sidebar: signer slot management, save, AI auto-place |
| `TemplatePicker` | `portal/_components/` | Dropdown/modal used in AddDocumentModal to choose a document_template |
### Modified Components
| Component | Change |
|-----------|--------|
| `AddDocumentModal` | Add "Start from template" option that calls a new `POST /api/templates/{id}/apply` endpoint |
| `FieldPlacer` | Add optional `templateId` prop (or make `docId` accept either); add `onSaveAsTemplate` callback. Alternatively: keep FieldPlacer document-only and build a thin TemplateFieldPlacer wrapper that remaps the API calls. |
**Recommendation on FieldPlacer:** Keep FieldPlacer document-only. Build `TemplateFieldPlacer` as a thin wrapper that intercepts the `persistFields` call (currently hardcoded to `/api/documents/{docId}/fields`) by accepting an `onPersist` callback prop, or by adding a `persistUrl` override prop. This is a 5-line change to FieldPlacer and avoids branching logic inside the 822-line component.
---
## New API Routes
| Route | Method | Purpose |
|-------|--------|---------|
| `GET /api/templates` | GET | List all document_templates |
| `POST /api/templates` | POST | Create new document_template (blank or from form_template) |
| `GET /api/templates/[id]` | GET | Fetch single template (with signatureFields) |
| `PATCH /api/templates/[id]` | PATCH | Save field layout + signer slots |
| `DELETE /api/templates/[id]` | DELETE | Soft or hard delete |
| `POST /api/templates/[id]/apply` | POST | Apply template to a new document (see flow below) |
---
## "Apply Template to Document" Operation
This is the core integration between templates and the existing creation flow.
```
POST /api/templates/{templateId}/apply
Body: { clientId, name, signerRoleMap: { "Seller": "email@client.com", ... } }
Server:
1. Fetch document_template row (fields + signerSlots)
2. Fetch form_templates row for the PDF filename (via template.formTemplateId)
3. Copy PDF file to uploads/clients/{clientId}/{docId}.pdf
4. Deep-clone signatureFields array
5. For each field: replace signerEmail (role string) with actual email from signerRoleMap
6. INSERT documents with: clientId, name, formTemplateId, filePath, signatureFields (resolved),
signers (built from signerRoleMap + slot colors)
7. Return created document
Client (AddDocumentModal):
- Shows template picker (list from GET /api/templates)
- Shows role-to-email assignment UI (one row per signerSlot, pre-populated from client.contacts)
- On submit: calls POST /api/templates/{id}/apply
- On 201: router.refresh() + onClose() — same pattern as current flow
```
The document created this way is indistinguishable from a hand-placed document.
The agent can still edit fields after creation — FieldPlacer works identically.
---
## Template Editor Architecture
```
/portal/(protected)/templates/[templateId]/page.tsx (Server Component)
→ fetches document_template row
→ renders TemplateEditorClient
TemplateEditorClient
→ state: fields (from template.signatureFields), signerSlots, dirty flag
→ layout: same 2-column grid as DocumentPageClient (PDF left, panel right)
Left: TemplateFieldPlacer
→ wraps FieldPlacer with persistUrl="/api/templates/{templateId}/fields"
→ OR: FieldPlacer gets onPersist callback prop (cleaner)
Right: TemplatePanel
→ Signer slot list (add/remove roles, assign colors)
→ Save button (PATCH /api/templates/{templateId})
→ AI Auto-Place button (reuses existing /api/documents/{docId}/ai-prepare pattern,
needs a parallel /api/templates/{id}/ai-prepare endpoint)
```
**AI auto-place for templates:** The existing `ai-prepare` route writes to `documents.signatureFields`. A parallel `POST /api/templates/{id}/ai-prepare` writes to `document_templates.signatureFields` instead. The extraction + classification logic in `src/lib/ai/` is document-agnostic and can be called from either route without modification.
---
## Data Flow Diagram
```
form_templates (PDF registry)
│ formTemplateId FK (optional)
document_templates (field layout + signer slots)
│ POST /api/templates/{id}/apply
│ → resolves role → email
documents (working copy, per client)
├── signatureFields JSONB ← FieldPlacer reads/writes
├── signers JSONB ← PreparePanel reads/writes
└── textFillData JSONB ← PreparePanel reads/writes
```
---
## Scalability Considerations
| Concern | Now (one agent) | Future (multi-agent SaaS) |
|---------|-----------------|--------------------------|
| Template ownership | All templates belong to one agent | Add `userId` FK to `document_templates` |
| Template sharing | N/A | Separate `shared_templates` or `userId: null` convention |
| Template versioning | Not needed | Add `version` int or duplicate-on-edit pattern |
| PDF storage | Local filesystem (uploads/) | Already abstracted in route — swap `copyFile` for S3 copy |
---
## Build Order
Dependencies flow downward — each step enables the next.
### Step 1: Schema migration
- Add `document_templates` table to `schema.ts`
- Add `TemplateSignerSlot` interface
- Write drizzle migration (`0012_document_templates.sql`)
- **No changes to existing tables. Safe to deploy alongside live system.**
### Step 2: Template CRUD API
- `GET /api/templates` — list
- `POST /api/templates` — create (blank shell, optionally linked to form_template)
- `GET /api/templates/[id]` — fetch
- `PATCH /api/templates/[id]` — save fields + signerSlots
- `DELETE /api/templates/[id]` — delete
- These routes follow the exact same auth + db pattern as `forms-library/route.ts` and `documents/[id]/route.ts`
### Step 3: FieldPlacer persistence abstraction
- Add `onPersist?: (fields: SignatureFieldData[]) => Promise<void>` prop to FieldPlacer
- When prop is provided, use it instead of the hardcoded `persistFields(docId, ...)` call
- Fallback: keep existing behavior when prop is absent
- **This is a non-breaking change — all existing consumers pass no prop, behavior unchanged**
### Step 4: Template editor UI
- `TemplateEditorClient` + `TemplatePanel` components
- `/portal/(protected)/templates/[templateId]/page.tsx` server page
- `/portal/(protected)/templates/page.tsx` list page
- Portal nav link ("Templates")
### Step 5: Apply template API + AddDocumentModal update
- `POST /api/templates/[id]/apply` — the apply operation (step 5 depends on step 1+2)
- Update `AddDocumentModal` to add "Start from template" path
- Role-to-email mapping UI (pre-seeded from `client.contacts`)
### Step 6: AI auto-place for templates (optional, can defer)
- `POST /api/templates/[id]/ai-prepare` — mirrors existing route, writes to `document_templates`
- Reuse `extractBlanks()` and `classifyFieldsWithAI()` from `src/lib/ai/` unchanged
---
## Anti-Patterns to Avoid
### Anti-Pattern 1: Extending `form_templates` with field data
**What goes wrong:** `form_templates` becomes overloaded. Some rows have fields, some don't. Every consumer must handle both states. The forms-library UI must differentiate "raw PDF" from "template with fields".
**Instead:** New `document_templates` table with an optional FK back to `form_templates`.
### Anti-Pattern 2: FieldPlacer knows about templates
**What goes wrong:** 822-line component gets template-vs-document branching throughout. Hard to test, easy to regress.
**Instead:** FieldPlacer gets a single `onPersist` callback. The caller decides what to do with the fields.
### Anti-Pattern 3: Storing actual signer emails in template fields
**What goes wrong:** Templates must be edited when a client's email changes. Template becomes client-specific, defeating the purpose.
**Instead:** Store role strings ("Seller", "Buyer") in `signerEmail`. Resolve to actual emails at apply time.
### Anti-Pattern 4: Applying template by mutating the template's PDF
**What goes wrong:** Templates get corrupted or have prepared PDF artifacts baked in.
**Instead:** Apply = copy raw PDF + clone + resolve fields. Template's PDF path is never touched.
### Anti-Pattern 5: Building a new field placer from scratch for templates
**What goes wrong:** Two diverging implementations of the same complex drag-drop logic.
**Instead:** Reuse FieldPlacer via the `onPersist` prop abstraction in Step 3.
---
## Sources
- Direct code inspection of all listed files (HIGH confidence — current state)
- Schema introspection of all 12 drizzle migrations
- No external sources needed — this is pure architecture derivation from existing patterns