Files
red/.planning/phases/19-template-editor-ui/19-01-PLAN.md

498 lines
20 KiB
Markdown
Raw Normal View History

---
phase: 19-template-editor-ui
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx
- teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx
- teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts
- teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts
- teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts
autonomous: true
requirements: [TMPL-05, TMPL-06, TMPL-07, TMPL-09]
must_haves:
truths:
- "FieldPlacer accepts an onPersist callback that replaces internal persistFields when provided"
- "FieldPlacer accepts a fieldsUrl prop that overrides the default /api/documents/{docId}/fields endpoint"
- "PdfViewer and PdfViewerWrapper pass through onPersist and fieldsUrl to FieldPlacer plus accept a fileUrl prop for PDF source"
- "GET /api/templates/[id]/file streams the form PDF from seeds/forms/"
- "GET /api/templates/[id]/fields returns the template signatureFields array"
- "POST /api/templates/[id]/ai-prepare extracts blanks and classifies fields with AI then writes to DB"
- "Templates link appears in portal nav between Clients and Profile"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx"
provides: "onPersist + fieldsUrl props on FieldPlacerProps interface"
contains: "onPersist"
- path: "teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts"
provides: "PDF file streaming for templates"
exports: ["GET"]
- path: "teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts"
provides: "Template fields JSON endpoint"
exports: ["GET"]
- path: "teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts"
provides: "AI auto-place for templates"
exports: ["POST"]
key_links:
- from: "FieldPlacer.tsx"
to: "onPersist callback"
via: "conditional call at 4 persistFields sites"
pattern: "onPersist.*next.*persistFields"
- from: "FieldPlacer.tsx"
to: "fieldsUrl || /api/documents/${docId}/fields"
via: "useEffect loadFields"
pattern: "fieldsUrl.*docId"
- from: "PdfViewer.tsx"
to: "fileUrl || /api/documents/${docId}/file"
via: "Document file prop and download href"
pattern: "fileUrl.*docId"
---
<objective>
Add optional `onPersist`, `fieldsUrl`, and `fileUrl` props to the FieldPlacer/PdfViewer/PdfViewerWrapper chain (non-breaking), create three template API routes (file, fields, ai-prepare), and add "Templates" to portal nav.
Purpose: Establish the infrastructure that Plan 02's template editor UI will consume. Existing document workflows are unaffected because all new props are optional with backwards-compatible defaults.
Output: Modified FieldPlacer/PdfViewer/PdfViewerWrapper with template-mode props; three new API routes under /api/templates/[id]/; PortalNav with Templates link.
</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/19-template-editor-ui/19-CONTEXT.md
@.planning/phases/19-template-editor-ui/19-RESEARCH.md
@.planning/phases/18-template-schema-and-crud-api/18-02-SUMMARY.md
<interfaces>
<!-- Key types and contracts the executor needs. -->
From src/lib/db/schema.ts:
```typescript
export interface SignatureFieldData {
id: string;
page: number;
x: number;
y: number;
width: number;
height: number;
type?: SignatureFieldType;
signerEmail?: string;
hint?: string; // Optional label shown to signer for client-text / client-checkbox fields
}
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(),
});
```
From FieldPlacer.tsx (current):
```typescript
interface FieldPlacerProps {
docId: string;
pageInfo: PageInfo | null;
currentPage: number;
children: React.ReactNode;
readOnly?: boolean;
onFieldsChanged?: () => void;
selectedFieldId?: string | null;
textFillData?: Record<string, string>;
onFieldSelect?: (fieldId: string | null) => void;
onFieldValueChange?: (fieldId: string, value: string) => void;
aiPlacementKey?: number;
signers?: DocumentSigner[];
unassignedFieldIds?: Set<string>;
}
// persistFields called at lines 324, 508, 575, 745
```
From PdfViewerWrapper.tsx (current props):
```typescript
{ docId: string; docStatus?: string; onFieldsChanged?: () => void;
selectedFieldId?: string | null; textFillData?: Record<string, string>;
onFieldSelect?: (fieldId: string | null) => void;
onFieldValueChange?: (fieldId: string, value: string) => void;
aiPlacementKey?: number; signers?: DocumentSigner[]; unassignedFieldIds?: Set<string>; }
```
From PdfViewer.tsx (same props as PdfViewerWrapper, uses docId at lines 85 and 110):
- Line 85: `href={/api/documents/${docId}/file}` (download link)
- Line 110: `file={/api/documents/${docId}/file}` (react-pdf Document source)
From /api/documents/[id]/ai-prepare/route.ts (pattern to copy):
```typescript
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
if (!process.env.OPENAI_API_KEY) return Response.json({ error: '...' }, { status: 503 });
// ... extractBlanks + classifyFieldsWithAI ...
}
```
From PortalNav.tsx (current):
```typescript
const navLinks = [
{ href: "/portal/dashboard", label: "Dashboard" },
{ href: "/portal/clients", label: "Clients" },
{ href: "/portal/profile", label: "Profile" },
];
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add onPersist + fieldsUrl props to FieldPlacer, fileUrl to PdfViewer/PdfViewerWrapper, and Templates to PortalNav</name>
<files>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx,
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx,
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx,
teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx
</files>
<read_first>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx,
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx,
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx,
teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx
</read_first>
<action>
**FieldPlacer.tsx — 3 changes:**
1. Add two optional props to `FieldPlacerProps` interface (after `unassignedFieldIds`):
```typescript
onPersist?: (fields: SignatureFieldData[]) => Promise<void> | void;
fieldsUrl?: string;
```
2. Destructure both new props in the function signature with defaults:
```typescript
export function FieldPlacer({ ..., onPersist, fieldsUrl }: FieldPlacerProps) {
```
3. Update the `useEffect` at line ~222 that fetches fields — replace the hardcoded URL:
```typescript
const res = await fetch(fieldsUrl ?? `/api/documents/${docId}/fields`);
```
Add `fieldsUrl` to the useEffect dependency array: `[docId, aiPlacementKey, fieldsUrl]`
4. Update all 4 `persistFields` call sites to conditionally call `onPersist` instead:
Line ~324 (handleDragEnd, after `setFields(next)`):
```typescript
if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
```
Line ~508 (handleZonePointerUp move branch, after `setFields(next)`):
```typescript
if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
```
Line ~575 (handleZonePointerUp resize branch, after `setFields(next)`):
```typescript
if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
```
Line ~745 (delete button onClick, after `setFields(next)`):
```typescript
if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
```
5. Add `onPersist` to the dependency arrays of the useCallback hooks that contain the 4 call sites:
- `handleDragEnd` (line ~327): add `onPersist` to `[fields, pageInfo, currentPage, docId, readOnly, onFieldsChanged, activeSignerEmail]`
- `handleZonePointerUp` (line ~578): add `onPersist` to `[docId, onFieldsChanged]`
- The delete button's onClick is inline in `renderFields``onPersist` is already in scope (function component body), no useCallback change needed.
Per D-01 and D-02 from CONTEXT.md. Backwards compatible: when `onPersist` is undefined, all existing callers get the original `persistFields(docId, next)` behavior.
**PdfViewer.tsx — 3 changes:**
1. Add three optional props to the inline type annotation of the function:
```typescript
onPersist?: (fields: import('@/lib/db/schema').SignatureFieldData[]) => Promise<void> | void;
fieldsUrl?: string;
fileUrl?: string;
```
(Import `SignatureFieldData` type from `@/lib/db/schema` at the top if not already imported.)
2. Destructure `onPersist`, `fieldsUrl`, `fileUrl` in the function params.
3. Replace the 2 hardcoded file URL usages:
- Line 85 download link: `href={fileUrl ?? \`/api/documents/${docId}/file\`}`
- Line 110 Document file prop: `file={fileUrl ?? \`/api/documents/${docId}/file\`}`
4. Pass `onPersist` and `fieldsUrl` through to `<FieldPlacer>` (lines 95-107):
```typescript
<FieldPlacer
docId={docId}
...existing props...
onPersist={onPersist}
fieldsUrl={fieldsUrl}
>
```
**PdfViewerWrapper.tsx — 3 changes:**
1. Add three optional props to the inline type annotation:
```typescript
onPersist?: (fields: import('@/lib/db/schema').SignatureFieldData[]) => Promise<void> | void;
fieldsUrl?: string;
fileUrl?: string;
```
(Import `SignatureFieldData` from `@/lib/db/schema` at the top.)
2. Destructure `onPersist`, `fieldsUrl`, `fileUrl` in the function params.
3. Pass all three through to `<PdfViewer>`:
```typescript
<PdfViewer
...existing props...
onPersist={onPersist}
fieldsUrl={fieldsUrl}
fileUrl={fileUrl}
/>
```
**PortalNav.tsx — 1 change (per D-17):**
Insert Templates link between Clients and Profile in the `navLinks` array:
```typescript
const navLinks = [
{ href: "/portal/dashboard", label: "Dashboard" },
{ href: "/portal/clients", label: "Clients" },
{ href: "/portal/templates", label: "Templates" },
{ href: "/portal/profile", label: "Profile" },
];
```
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- FieldPlacer.tsx contains `onPersist?: (fields: SignatureFieldData[]) => Promise<void> | void`
- FieldPlacer.tsx contains `fieldsUrl?: string`
- FieldPlacer.tsx contains `fieldsUrl ??` in the loadFields useEffect fetch URL
- FieldPlacer.tsx contains `if (onPersist)` at all 4 persistFields call sites (lines ~324, ~508, ~575, ~745)
- PdfViewer.tsx contains `fileUrl?: string`
- PdfViewer.tsx contains `fileUrl ??` before `/api/documents/${docId}/file` in both the download href and the Document file prop
- PdfViewer.tsx passes `onPersist={onPersist}` and `fieldsUrl={fieldsUrl}` to FieldPlacer
- PdfViewerWrapper.tsx passes `onPersist`, `fieldsUrl`, `fileUrl` through to PdfViewer
- PortalNav.tsx contains `{ href: "/portal/templates", label: "Templates" }`
- `npx tsc --noEmit` exits 0 (no type errors)
</acceptance_criteria>
<done>All three viewer components accept optional template-mode props (onPersist, fieldsUrl, fileUrl) with backwards-compatible defaults. PortalNav shows Templates link. TypeScript compiles clean.</done>
</task>
<task type="auto">
<name>Task 2: Create three template API routes — file, fields, ai-prepare</name>
<files>
teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts,
teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts,
teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts
</files>
<read_first>
teressa-copeland-homes/src/app/api/templates/[id]/route.ts,
teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts,
teressa-copeland-homes/src/lib/db/schema.ts,
teressa-copeland-homes/src/lib/ai/extract-text.ts,
teressa-copeland-homes/src/lib/ai/field-placement.ts
</read_first>
<action>
**Create `src/app/api/templates/[id]/file/route.ts`** — GET handler that streams the template's source PDF from `seeds/forms/`:
```typescript
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documentTemplates } from '@/lib/db/schema';
import { and, eq, isNull } from 'drizzle-orm';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms');
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
const template = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
with: { formTemplate: true },
});
if (!template?.formTemplate) return Response.json({ error: 'Not found' }, { status: 404 });
const filePath = path.join(SEEDS_FORMS_DIR, template.formTemplate.filename);
// Path traversal guard
if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 });
try {
const file = await readFile(filePath);
return new Response(file, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${template.formTemplate.filename}"`,
},
});
} catch {
return Response.json({ error: 'Form PDF not found on disk' }, { status: 404 });
}
}
```
**Create `src/app/api/templates/[id]/fields/route.ts`** — GET handler returning `signatureFields ?? []`:
```typescript
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documentTemplates } from '@/lib/db/schema';
import { and, eq, isNull } from 'drizzle-orm';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
const template = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
});
if (!template) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json(template.signatureFields ?? []);
}
```
**Create `src/app/api/templates/[id]/ai-prepare/route.ts`** — POST handler (per D-14, D-15):
Copy pattern from `/api/documents/[id]/ai-prepare/route.ts` with these differences:
- Load from `documentTemplates` (with `formTemplate` relation) instead of `documents`
- PDF path is `path.join(SEEDS_FORMS_DIR, template.formTemplate.filename)` instead of uploads dir
- No Draft status check (templates have no status)
- Pass `null` as client context to `classifyFieldsWithAI` (per D-15 — no client pre-fill)
- Write result to `documentTemplates.signatureFields` via `db.update(documentTemplates).set(...)` with explicit `updatedAt: new Date()`
- Return `{ fields }` only (no `textFillData` — templates don't use it)
```typescript
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documentTemplates } from '@/lib/db/schema';
import { and, eq, isNull } from 'drizzle-orm';
import path from 'node:path';
import { extractBlanks } from '@/lib/ai/extract-text';
import { classifyFieldsWithAI } from '@/lib/ai/field-placement';
const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms');
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
if (!process.env.OPENAI_API_KEY) {
return Response.json(
{ error: 'OPENAI_API_KEY not configured. Add it to .env.local.' },
{ status: 503 }
);
}
const { id } = await params;
const template = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
with: { formTemplate: true },
});
if (!template?.formTemplate) return Response.json({ error: 'Not found' }, { status: 404 });
const filePath = path.join(SEEDS_FORMS_DIR, template.formTemplate.filename);
if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 });
try {
const blanks = await extractBlanks(filePath);
const { fields } = await classifyFieldsWithAI(blanks, null); // null = no client context per D-15
const [updated] = await db
.update(documentTemplates)
.set({ signatureFields: fields, updatedAt: new Date() })
.where(eq(documentTemplates.id, id))
.returning();
return Response.json({ fields: updated.signatureFields ?? [] });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 500 });
}
}
```
All three routes follow the same auth guard + soft-delete filter (`isNull(archivedAt)`) pattern established in Phase 18.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- File exists: `src/app/api/templates/[id]/file/route.ts`
- File exists: `src/app/api/templates/[id]/fields/route.ts`
- File exists: `src/app/api/templates/[id]/ai-prepare/route.ts`
- file/route.ts contains `export async function GET`
- file/route.ts contains `SEEDS_FORMS_DIR` and `readFile(filePath)`
- file/route.ts contains `!filePath.startsWith(SEEDS_FORMS_DIR)` path traversal guard
- fields/route.ts contains `export async function GET`
- fields/route.ts contains `template.signatureFields ?? []`
- ai-prepare/route.ts contains `export async function POST`
- ai-prepare/route.ts contains `classifyFieldsWithAI(blanks, null)` (null client context)
- ai-prepare/route.ts contains `updatedAt: new Date()` in the update set
- ai-prepare/route.ts contains `isNull(documentTemplates.archivedAt)` in the where clause
- `npx tsc --noEmit` exits 0
</acceptance_criteria>
<done>Three template API routes created and type-checking. GET /api/templates/[id]/file streams the form PDF. GET /api/templates/[id]/fields returns signatureFields. POST /api/templates/[id]/ai-prepare runs AI field placement with null client context and writes to DB.</done>
</task>
</tasks>
<verification>
1. `cd teressa-copeland-homes && npx tsc --noEmit` exits 0 — no type errors introduced
2. `npm run build` succeeds — no build-time errors
3. Grep confirms: `onPersist` appears in FieldPlacer, PdfViewer, PdfViewerWrapper
4. Grep confirms: `fieldsUrl` appears in FieldPlacer, PdfViewer, PdfViewerWrapper
5. Grep confirms: `fileUrl` appears in PdfViewer, PdfViewerWrapper
6. Grep confirms: `"/portal/templates"` appears in PortalNav
7. Three new route files exist under `src/app/api/templates/[id]/`
</verification>
<success_criteria>
- FieldPlacer/PdfViewer/PdfViewerWrapper accept template-mode props without breaking existing document workflows
- All three template API routes respond to their HTTP methods with proper auth guards and soft-delete filters
- PortalNav shows "Templates" link in the correct position (between Clients and Profile)
- TypeScript compiles clean across the entire project
</success_criteria>
<output>
After completion, create `.planning/phases/19-template-editor-ui/19-01-SUMMARY.md`
</output>