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