20 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 19-template-editor-ui | 01 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom src/lib/db/schema.ts:
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):
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):
{ 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):
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):
const navLinks = [
{ href: "/portal/dashboard", label: "Dashboard" },
{ href: "/portal/clients", label: "Clients" },
{ href: "/portal/profile", label: "Profile" },
];
- Add two optional props to
FieldPlacerPropsinterface (afterunassignedFieldIds):
onPersist?: (fields: SignatureFieldData[]) => Promise<void> | void;
fieldsUrl?: string;
- Destructure both new props in the function signature with defaults:
export function FieldPlacer({ ..., onPersist, fieldsUrl }: FieldPlacerProps) {
- Update the
useEffectat line ~222 that fetches fields — replace the hardcoded URL:
const res = await fetch(fieldsUrl ?? `/api/documents/${docId}/fields`);
Add fieldsUrl to the useEffect dependency array: [docId, aiPlacementKey, fieldsUrl]
- Update all 4
persistFieldscall sites to conditionally callonPersistinstead:
Line ~324 (handleDragEnd, after setFields(next)):
if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
Line ~508 (handleZonePointerUp move branch, after setFields(next)):
if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
Line ~575 (handleZonePointerUp resize branch, after setFields(next)):
if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
Line ~745 (delete button onClick, after setFields(next)):
if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
- Add
onPersistto the dependency arrays of the useCallback hooks that contain the 4 call sites:
handleDragEnd(line ~327): addonPersistto[fields, pageInfo, currentPage, docId, readOnly, onFieldsChanged, activeSignerEmail]handleZonePointerUp(line ~578): addonPersistto[docId, onFieldsChanged]- The delete button's onClick is inline in
renderFields—onPersistis 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:
- Add three optional props to the inline type annotation of the function:
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.)
-
Destructure
onPersist,fieldsUrl,fileUrlin the function params. -
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`}`
- Pass
onPersistandfieldsUrlthrough to<FieldPlacer>(lines 95-107):
<FieldPlacer
docId={docId}
...existing props...
onPersist={onPersist}
fieldsUrl={fieldsUrl}
>
PdfViewerWrapper.tsx — 3 changes:
- Add three optional props to the inline type annotation:
onPersist?: (fields: import('@/lib/db/schema').SignatureFieldData[]) => Promise<void> | void;
fieldsUrl?: string;
fileUrl?: string;
(Import SignatureFieldData from @/lib/db/schema at the top.)
-
Destructure
onPersist,fieldsUrl,fileUrlin the function params. -
Pass all three through to
<PdfViewer>:
<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:
const navLinks = [
{ href: "/portal/dashboard", label: "Dashboard" },
{ href: "/portal/clients", label: "Clients" },
{ href: "/portal/templates", label: "Templates" },
{ href: "/portal/profile", label: "Profile" },
];
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 ?? []:
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(withformTemplaterelation) instead ofdocuments - PDF path is
path.join(SEEDS_FORMS_DIR, template.formTemplate.filename)instead of uploads dir - No Draft status check (templates have no status)
- Pass
nullas client context toclassifyFieldsWithAI(per D-15 — no client pre-fill) - Write result to
documentTemplates.signatureFieldsviadb.update(documentTemplates).set(...)with explicitupdatedAt: new Date() - Return
{ fields }only (notextFillData— templates don't use it)
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.
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
<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>
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.
<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>