Files
red/teressa-copeland-homes/.planning/research/ARCHITECTURE.md
2026-04-06 11:54:40 -06:00

15 KiB
Raw Blame History

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

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