217 lines
9.2 KiB
Markdown
217 lines
9.2 KiB
Markdown
|
|
# 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`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 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:
|
|||
|
|
|
|||
|
|
1. Load `document_templates` row (fields, signerRoles, textHints)
|
|||
|
|
2. Create a new `documents` row with `formTemplateId` copied from the template's source
|
|||
|
|
3. Deep-clone `signatureFields` array; substitute role strings → real signer emails
|
|||
|
|
supplied by the agent at apply-time (e.g. "Seller" → "alice@example.com")
|
|||
|
|
4. Copy `textHints` into `documents.textFillData` as the initial fill data
|
|||
|
|
5. `documents.signers` is 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.
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 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 `clientId` context (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
|
|||
|
|
|
|||
|
|
1. **Role-string convention in `signerEmail` field:** The `SignatureFieldData.signerEmail`
|
|||
|
|
field 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.
|
|||
|
|
|
|||
|
|
2. **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).
|
|||
|
|
|
|||
|
|
3. **`updatedAt` auto-update:** The current schema uses `.defaultNow()` for `updatedAt`
|
|||
|
|
but does not use a DB trigger for auto-update on mutation. Existing tables share this
|
|||
|
|
pattern — use the same convention (set `updatedAt` explicitly 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)
|