9.2 KiB
Technology Stack — Document Templates Feature
Project: teressa-copeland-homes Feature: Document Templates (reusable PDF + pre-placed fields + signer role assignments) Researched: 2026-04-06 Overall confidence: HIGH — analysis is based on direct inspection of the live codebase
Verdict: Zero New Packages Required
The existing stack handles document templates entirely. This feature is a schema extension plus UI reuse — no new runtime dependencies are needed.
Existing Stack (Validated — Do Not Re-research)
| Layer | Technology | Version |
|---|---|---|
| Framework | Next.js App Router | 16.2.0 |
| Language | TypeScript | ^5 |
| ORM | Drizzle ORM | ^0.45.1 |
| Database | PostgreSQL (local dev / Neon prod) | — |
| PDF manipulation | @cantoo/pdf-lib | ^2.6.3 |
| PDF rendering | react-pdf | ^10.4.1 |
| Drag-drop fields | @dnd-kit/core + @dnd-kit/utilities | ^6.3.1 / ^3.2.2 |
| AI field placement | openai (GPT-4.1) | ^6.32.0 |
| Validation | zod | ^4.3.6 |
| Auth | next-auth | 5.0.0-beta.30 |
Schema Change: New document_templates Table
The current form_templates table stores only raw PDFs (id, name, filename). It has no
field data. A new table is needed — it must NOT be confused with form_templates, which
is a forms library (source PDFs only).
New table: document_templates
// src/lib/db/schema.ts addition
export interface TemplateSignerRole {
role: string; // e.g. "Seller", "Buyer", "Agent"
color: string; // hex — matches signer color convention from DocumentSigner
}
export const documentTemplates = pgTable("document_templates", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
// FK to form_templates — the source PDF this template was built from
formTemplateId: text("form_template_id")
.notNull()
.references(() => formTemplates.id, { onDelete: "restrict" }),
// Pre-placed fields — same SignatureFieldData[] shape used everywhere else
// signerEmail on each field will hold a ROLE string here (e.g. "Seller"),
// not a real email. Resolved to actual emails at apply-time.
signatureFields: jsonb("signature_fields").$type<SignatureFieldData[]>(),
// Ordered list of signer roles with their assigned colors
signerRoles: jsonb("signer_roles").$type<TemplateSignerRole[]>(),
// Pre-filled static text hints for text/client-text fields
textHints: jsonb("text_hints").$type<Record<string, string>>(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
Why a new table, not extending form_templates
form_templates is a seeded forms library (120+ Utah/CCIM PDF forms). It is read-only
catalog data. Adding mutable agent-authored field data to it would mix concerns: catalog
vs. prepared template. A clean FK relationship (document_templates.formTemplateId → form_templates.id) keeps them separated and allows one form to have multiple named
templates (e.g. "Standard Listing Agreement", "Listing Agreement + Short Sale Addendum").
Why not extend documents
documents is a per-client instance with a concrete clientId, real signer emails, and
a lifecycle (Draft → Sent → Signed). Templates are agent-owned, have no client, and have
no signing lifecycle. Mixing them would require nullable foreign keys and conditional
logic throughout the signing flow.
Apply-Template Operation: Pure Data Transform, No New Packages
When an agent applies a template to a new document, the operation is:
- Load
document_templatesrow (fields, signerRoles, textHints) - Create a new
documentsrow withformTemplateIdcopied from the template's source - Deep-clone
signatureFieldsarray; substitute role strings → real signer emails supplied by the agent at apply-time (e.g. "Seller" → "alice@example.com") - Copy
textHintsintodocuments.textFillDataas the initial fill data documents.signersis populated from the resolved email+color pairs
This is entirely Drizzle + TypeScript. No transformation library needed.
JSONB Performance: No Concern at This Scale
Field for concern: signatureFields JSONB arrays in both document_templates and
documents. Typical Utah real estate form: 10–60 fields. Maximum realistic: ~150 fields
on a complex addendum stack.
PostgreSQL JSONB behavior: JSONB is stored binary, fully indexed when needed. At
< 200 fields per document and < 1000 total templates, there is no performance risk with
unindexed JSONB. The existing documents.signatureFields already uses this pattern at
production with no issues.
If GIN indexing were ever needed (e.g. querying "all templates that have a field of
type X"), drizzle-kit supports it via index(). Not needed for the MVP.
Migration Path
This is an additive migration — no existing tables are altered.
-- Generated by: npx drizzle-kit generate
CREATE TABLE "document_templates" (
"id" text PRIMARY KEY,
"name" text NOT NULL,
"form_template_id" text NOT NULL REFERENCES "form_templates"("id") ON DELETE RESTRICT,
"signature_fields" jsonb,
"signer_roles" jsonb,
"text_hints" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
Follow existing migration pattern: add to schema.ts, run npm run db:generate, then
npm run db:migrate.
UI Reuse
All FieldPlacer / DocumentPageClient / PreparePanel / PdfViewerWrapper components can be reused verbatim for the template editor. The template editor is functionally identical to the document prepare flow — same drag-drop, same AI auto-place endpoint — except:
- No
clientIdcontext (no real signer emails yet; use role names as placeholder emails) - No "Send" action (templates are saved, not sent)
- Signer panel shows roles ("Seller", "Buyer") not real email addresses
This means the template editor is a new route
(/portal/templates/[templateId]/edit) that instantiates the existing components with
role-mode props, not a new component tree.
Integration Points
| Existing File | Change Needed |
|---|---|
src/lib/db/schema.ts |
Add documentTemplates table + TemplateSignerRole interface |
src/app/api/documents/route.ts |
Accept optional documentTemplateId on POST; apply-template transform |
src/app/portal/_components/AddDocumentModal.tsx |
Add "From template" tab alongside "From forms library" |
drizzle/ |
New migration file (auto-generated by drizzle-kit) |
No changes needed to: signing flow, PDF preparation, email sending, audit events.
Alternatives Considered
| Option | Why Rejected |
|---|---|
Extend form_templates with field columns |
Mixes catalog data with agent-authored data; blocks multi-template-per-form |
Store template fields inside documents with a isTemplate flag |
Breaks document lifecycle invariants; complicates all document queries |
| New npm package for template merge (e.g. json-merge-patch) | Not needed — deep clone + email substitution is trivial TypeScript |
| Separate template file storage (copy PDF per template) | Wasteful; templates share the original form_templates PDF via FK |
Confidence Assessment
| Area | Confidence | Basis |
|---|---|---|
| Zero new packages | HIGH | Direct schema/code inspection; transform is pure TypeScript |
| New table over extending existing | HIGH | form_templates is seeded catalog; concerns are clearly different |
| JSONB performance | HIGH | Existing pattern already in production; field counts well within limits |
| UI reuse via props | MEDIUM | Components not yet inspected for hardcoded assumptions about signer emails vs roles — confirm before implementation |
| Migration is additive | HIGH | No existing column changes; FK is to read-only catalog table |
Open Questions for Implementation Phase
-
Role-string convention in
signerEmailfield: TheSignatureFieldData.signerEmailfield currently holds real email addresses. Using it for role strings ("Seller") during template editing requires the FieldPlacer and PreparePanel components to handle both modes. Confirm these components have no email-format validation before proceeding. -
Template apply: partial field substitution. If a template has 3 signer roles but the agent assigns only 2 real signers at apply-time, the behavior needs to be defined (error, or leave unassigned fields with no signer).
-
updatedAtauto-update: The current schema uses.defaultNow()forupdatedAtbut does not use a DB trigger for auto-update on mutation. Existing tables share this pattern — use the same convention (setupdatedAtexplicitly in update queries).
Sources
- Direct inspection:
/Users/ccopeland/temp/red/teressa-copeland-homes/src/lib/db/schema.ts - Direct inspection:
/Users/ccopeland/temp/red/teressa-copeland-homes/package.json - Direct inspection:
/Users/ccopeland/temp/red/teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx - Direct inspection:
/Users/ccopeland/temp/red/teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts - Direct inspection:
/Users/ccopeland/temp/red/teressa-copeland-homes/drizzle/(migrations 0000–0011)