15 KiB
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 (0000–0011) |
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
aiPlacementKeyprop 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.tsreturns{ id, name, filename }onlydocuments/route.tsuses it only to resolvetemplate.filenameforcopyFile()AddDocumentModalrenders it as a name list for file selectiondocuments.formTemplateIdis a FK used only for audit/traceability
Adding signatureFields + signers JSONB to form_templates would:
- Break the clear single-responsibility of that table
- Force every consumer that reads
form_templatesto handle nullable field data - Make the "template has fields" state conflated with "template is just a PDF"
- Create confusion: which
form_templatesrows 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_templatescontinues working exactly as it does today- The new table has an optional FK to
form_templatesfor traceability
Proposed Schema Addition
// 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_templatestable toschema.ts - Add
TemplateSignerSlotinterface - 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— listPOST /api/templates— create (blank shell, optionally linked to form_template)GET /api/templates/[id]— fetchPATCH /api/templates/[id]— save fields + signerSlotsDELETE /api/templates/[id]— delete- These routes follow the exact same auth + db pattern as
forms-library/route.tsanddocuments/[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+TemplatePanelcomponents/portal/(protected)/templates/[templateId]/page.tsxserver page/portal/(protected)/templates/page.tsxlist 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
AddDocumentModalto 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 todocument_templates- Reuse
extractBlanks()andclassifyFieldsWithAI()fromsrc/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