# 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 `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(), // Signer role slots — defines which roles exist and their colors signerSlots: jsonb("signer_slots").$type(), 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` 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