docs(phase-20): create phase plan — apply template and portal nav

This commit is contained in:
Chandler Copeland
2026-04-06 14:10:38 -06:00
parent 9081342e1b
commit af5beaf5cb
3 changed files with 685 additions and 1 deletions

View File

@@ -0,0 +1,422 @@
---
phase: 20-apply-template-and-portal-nav
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx
- teressa-copeland-homes/src/app/api/documents/route.ts
autonomous: true
requirements: [TMPL-10, TMPL-11, TMPL-12, TMPL-14]
must_haves:
truths:
- "Agent sees a 'My Templates' tab in the Add Document modal alongside the existing Forms Library"
- "Agent can pick a saved template and click Add Document to create a document with all template fields pre-loaded at their saved positions"
- "Every field copied from a template has a fresh UUID — no template field ID appears in the new document"
- "Template signer roles are auto-mapped to client contacts (first role to client email, second role to co-buyer email)"
- "Editing a template afterward does not change the document's fields (snapshot independence)"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx"
provides: "Two-tab modal with Forms Library + My Templates"
contains: "activeTab"
- path: "teressa-copeland-homes/src/app/api/documents/route.ts"
provides: "Template apply branch in POST handler"
contains: "documentTemplateId"
key_links:
- from: "AddDocumentModal.tsx"
to: "POST /api/documents"
via: "fetch with documentTemplateId in JSON body"
pattern: "documentTemplateId.*selectedDocTemplate"
- from: "POST /api/documents"
to: "documentTemplates table"
via: "db.query.documentTemplates.findFirst"
pattern: "documentTemplates"
- from: "POST /api/documents"
to: "clients table"
via: "db.query.clients.findFirst for role-to-email mapping"
pattern: "clients\\.id.*clientId"
---
<objective>
Add "My Templates" tab to AddDocumentModal and extend POST /api/documents to apply a template — copying fields with fresh UUIDs and auto-mapping signer roles to client contacts.
Purpose: Lets the agent start a new document from a saved template instead of a blank form, eliminating repetitive field placement on commonly-used PDFs.
Output: Modified AddDocumentModal.tsx with two tabs, extended POST /api/documents route with documentTemplateId branch.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/20-apply-template-and-portal-nav/20-CONTEXT.md
@.planning/phases/20-apply-template-and-portal-nav/20-RESEARCH.md
<interfaces>
<!-- Key types and contracts. Extracted from codebase. Executor should use directly. -->
From src/lib/db/schema.ts:
```typescript
export interface SignatureFieldData {
id: string;
page: number; // 1-indexed
x: number; // PDF user space, bottom-left origin, points
y: number;
width: number;
height: number;
type?: SignatureFieldType;
signerEmail?: string; // In templates: carries role labels like "Buyer", "Seller"
hint?: string; // Optional label for client-text fields
}
export interface ClientContact {
name: string;
email: string;
}
export interface DocumentSigner {
email: string;
color: string;
}
export const documentTemplates = pgTable("document_templates", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
formTemplateId: text("form_template_id").notNull().references(() => formTemplates.id),
signatureFields: jsonb("signature_fields").$type<SignatureFieldData[]>(),
archivedAt: timestamp("archived_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const documentTemplatesRelations = relations(documentTemplates, ({ one }) => ({
formTemplate: one(formTemplates, { fields: [documentTemplates.formTemplateId], references: [formTemplates.id] }),
}));
```
From src/app/api/templates/route.ts GET response shape:
```typescript
// Each row in the JSON array:
{
id: string;
name: string;
formTemplateId: string;
formName: string | null;
fieldCount: number;
createdAt: Date;
updatedAt: Date;
}
```
From src/app/api/documents/route.ts — existing POST handler structure:
```typescript
// Content-type branching:
// - multipart/form-data → custom PDF upload (reads file from FormData)
// - else (JSON) → form-library copy (reads formTemplateId, copies PDF from seeds/)
// Both paths: create destDir, copy/write PDF, INSERT into documents table
// The JSON branch is what we extend with documentTemplateId support
```
From src/app/portal/_components/AddDocumentModal.tsx — existing state:
```typescript
type FormTemplate = { id: string; name: string; filename: string };
// Props: { clientId: string; onClose: () => void }
// State: templates (FormTemplate[]), selectedTemplate, customFile, docName, query
// Submit: customFile → FormData POST, else → JSON POST with formTemplateId
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Extend POST /api/documents with documentTemplateId branch</name>
<files>teressa-copeland-homes/src/app/api/documents/route.ts</files>
<read_first>
teressa-copeland-homes/src/app/api/documents/route.ts
teressa-copeland-homes/src/lib/db/schema.ts
teressa-copeland-homes/src/app/api/templates/route.ts
</read_first>
<action>
Extend the JSON body parsing in the POST handler to also extract `documentTemplateId: string | undefined` from `body`.
After the existing `if (!clientId || !name)` guard, add a new branch BEFORE the existing `formTemplateId` branch. The logic:
```
if (documentTemplateId) {
// 1. Fetch document template with its formTemplate relation
const docTemplate = await db.query.documentTemplates.findFirst({
where: eq(documentTemplates.id, documentTemplateId),
with: { formTemplate: true },
});
if (!docTemplate) return Response.json({ error: 'Document template not found' }, { status: 404 });
// 2. Copy PDF from seeds dir using the form template's filename
const srcPath = path.join(SEEDS_DIR, docTemplate.formTemplate.filename);
await copyFile(srcPath, destPath);
// 3. Copy fields with fresh UUIDs (D-07) — hints preserved verbatim (D-09)
const rawFields: SignatureFieldData[] = (docTemplate.signatureFields as SignatureFieldData[] | null) ?? [];
const copiedFields: SignatureFieldData[] = rawFields.map(f => ({
...f,
id: crypto.randomUUID(),
}));
// 4. Role-to-email mapping (D-06)
// Collect unique role labels in order of first appearance
const seenRoles = new Set<string>();
const uniqueRoles: string[] = [];
for (const f of copiedFields) {
if (f.signerEmail && !seenRoles.has(f.signerEmail)) {
seenRoles.add(f.signerEmail);
uniqueRoles.push(f.signerEmail);
}
}
// Fetch client for email + contacts
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
const clientEmails = [
client?.email,
...((client?.contacts as ClientContact[] | null) ?? []).map(c => c.email),
].filter(Boolean) as string[];
const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
const mappedSigners: DocumentSigner[] = uniqueRoles.map((role, i) => ({
email: clientEmails[i] ?? role,
color: SIGNER_COLORS[i % SIGNER_COLORS.length],
}));
// 5. Override formTemplateId for the DB insert
formTemplateId = docTemplate.formTemplateId;
// 6. Insert with fields and signers
const [doc] = await db.insert(documents).values({
id: docId,
clientId,
name,
formTemplateId: formTemplateId ?? null,
filePath: relPath,
status: 'Draft',
signatureFields: copiedFields,
signers: mappedSigners.length > 0 ? mappedSigners : null,
}).returning();
return Response.json(doc, { status: 201 });
}
```
The existing `else` path (formTemplateId from form library) and file upload path remain completely unchanged.
Add imports at top: `import { documentTemplates, clients } from '@/lib/db/schema';` and `import type { SignatureFieldData, ClientContact, DocumentSigner } from '@/lib/db/schema';`. The `clients` import is new; `documents` and `formTemplates` are already imported.
Also parse `documentTemplateId` from body: add `let documentTemplateId: string | undefined;` alongside existing declarations, and in the JSON else block add `documentTemplateId = body.documentTemplateId;`.
IMPORTANT: The template branch has its OWN db.insert + return, so the existing insert at the bottom of the function only runs for the non-template paths. Structure it so the template branch returns early.
</action>
<verify>
<automated>cd teressa-copeland-homes && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- grep "documentTemplateId" src/app/api/documents/route.ts returns at least 3 matches (declaration, parse, condition)
- grep "crypto.randomUUID" src/app/api/documents/route.ts returns at least 2 matches (docId + field copy)
- grep "signatureFields: copiedFields" src/app/api/documents/route.ts returns 1 match
- grep "SIGNER_COLORS" src/app/api/documents/route.ts returns at least 1 match
- grep "clientEmails" src/app/api/documents/route.ts returns at least 1 match
- grep "import.*clients" src/app/api/documents/route.ts returns 1 match (clients table import)
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>POST /api/documents accepts documentTemplateId, copies fields with fresh UUIDs, maps roles to client contacts, inserts document with signatureFields and signers</done>
</task>
<task type="auto">
<name>Task 2: Add My Templates tab to AddDocumentModal</name>
<files>teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx</files>
<read_first>
teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx
</read_first>
<action>
Add state variables for the template tab:
```typescript
type DocumentTemplateRow = {
id: string;
name: string;
formName: string | null;
fieldCount: number;
updatedAt: string;
};
const [activeTab, setActiveTab] = useState<'forms' | 'templates'>('forms');
const [docTemplates, setDocTemplates] = useState<DocumentTemplateRow[]>([]);
const [docTemplatesLoaded, setDocTemplatesLoaded] = useState(false);
const [selectedDocTemplate, setSelectedDocTemplate] = useState<DocumentTemplateRow | null>(null);
```
Add lazy fetch function — fetches templates only on first click of the My Templates tab:
```typescript
function handleSwitchToTemplates() {
setActiveTab('templates');
if (!docTemplatesLoaded) {
fetch('/api/templates')
.then(r => r.json())
.then((data: DocumentTemplateRow[]) => { setDocTemplates(data); setDocTemplatesLoaded(true); })
.catch(console.error);
}
}
```
Add mutual exclusivity to selection handlers:
- In `handleSelectTemplate` (existing form-library handler): add `setSelectedDocTemplate(null);` at top
- Add new handler for document template selection:
```typescript
const handleSelectDocTemplate = (t: DocumentTemplateRow) => {
setSelectedDocTemplate(t);
setSelectedTemplate(null);
setCustomFile(null);
setDocName(t.name);
};
```
Extend `handleSubmit`:
- Add a NEW branch at the top of the try block, before the existing `if (customFile)`:
```typescript
if (selectedDocTemplate) {
await fetch('/api/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId,
name: docName.trim(),
documentTemplateId: selectedDocTemplate.id,
}),
});
} else if (customFile) {
// ... existing custom file path unchanged
} else {
// ... existing form library path unchanged
}
```
Update the early-return guard in handleSubmit:
```typescript
if (!docName.trim() || (!selectedTemplate && !customFile && !selectedDocTemplate)) return;
```
Update the submit button disabled condition:
```typescript
disabled={saving || (!selectedTemplate && !customFile && !selectedDocTemplate) || !docName.trim()}
```
Render two tab buttons between the h2 heading and the search input. Use underline-style tabs matching the project's plain Tailwind approach:
```tsx
<div className="flex border-b mb-4">
<button
type="button"
onClick={() => setActiveTab('forms')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === 'forms'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Forms Library
</button>
<button
type="button"
onClick={handleSwitchToTemplates}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === 'templates'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
My Templates
</button>
</div>
```
Conditionally render tab content:
- When `activeTab === 'forms'`: show the existing search input + form list + custom file upload section (ALL existing markup unchanged)
- When `activeTab === 'templates'`: show the templates list:
```tsx
{activeTab === 'templates' && (
<div className="mb-4">
{!docTemplatesLoaded ? (
<p className="text-sm text-gray-500 py-4 text-center">Loading templates...</p>
) : docTemplates.length === 0 ? (
<p className="text-sm text-gray-500 py-4 text-center">
No templates saved yet. Create one from the Templates page.
</p>
) : (
<ul className="border rounded max-h-48 overflow-y-auto">
{docTemplates.map(t => (
<li
key={t.id}
onClick={() => handleSelectDocTemplate(t)}
className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 ${
selectedDocTemplate?.id === t.id ? 'bg-blue-50 font-medium' : ''
}`}
>
<span className="block">{t.name}</span>
<span className="text-xs text-gray-400">
{t.formName ?? 'Unknown form'} &middot; {t.fieldCount} field{t.fieldCount !== 1 ? 's' : ''}
</span>
</li>
))}
</ul>
)}
</div>
)}
```
The `{activeTab === 'forms' && ( ... )}` wraps around the existing search input, form list `<ul>`, and custom file upload `<div>`. All existing elements are preserved exactly — only wrapped in a conditional.
</action>
<verify>
<automated>cd teressa-copeland-homes && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- grep "activeTab" src/app/portal/_components/AddDocumentModal.tsx returns at least 4 matches
- grep "My Templates" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match
- grep "Forms Library" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match
- grep "selectedDocTemplate" src/app/portal/_components/AddDocumentModal.tsx returns at least 3 matches
- grep "documentTemplateId" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match
- grep "/api/templates" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match
- grep "No templates saved yet" src/app/portal/_components/AddDocumentModal.tsx returns 1 match (empty state)
- npx tsc --noEmit exits 0
</acceptance_criteria>
<done>AddDocumentModal has two tabs (Forms Library + My Templates), lazy-loads templates on first tab click, sends documentTemplateId to POST /api/documents when a template is selected</done>
</task>
</tasks>
<verification>
1. `cd teressa-copeland-homes && npx tsc --noEmit` — zero type errors
2. `cd teressa-copeland-homes && npm run build` — production build succeeds
3. grep confirms: documentTemplateId in both route.ts and AddDocumentModal.tsx
4. grep confirms: crypto.randomUUID in route.ts field copy
5. grep confirms: SIGNER_COLORS in route.ts
6. grep confirms: activeTab, selectedDocTemplate in AddDocumentModal.tsx
</verification>
<success_criteria>
- POST /api/documents accepts documentTemplateId and creates a document with copied fields (fresh UUIDs), mapped signers, and correct formTemplateId
- AddDocumentModal shows two tabs; My Templates tab fetches from GET /api/templates and displays template rows
- Selecting a template and clicking Add Document creates a document via the template branch
- No changes to existing Forms Library tab or custom file upload behavior
- TypeScript compiles with zero errors
</success_criteria>
<output>
After completion, create `.planning/phases/20-apply-template-and-portal-nav/20-01-SUMMARY.md`
</output>