Files
red/.planning/phases/19-template-editor-ui/19-01-PLAN.md
2026-04-06 12:46:48 -06:00

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
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
true
TMPL-05
TMPL-06
TMPL-07
TMPL-09
truths artifacts key_links
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
path provides contains
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx onPersist + fieldsUrl props on FieldPlacerProps interface onPersist
path provides exports
teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts PDF file streaming for templates
GET
path provides exports
teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts Template fields JSON endpoint
GET
path provides exports
teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts AI auto-place for templates
POST
from to via pattern
FieldPlacer.tsx onPersist callback conditional call at 4 persistFields sites onPersist.*next.*persistFields
from to via pattern
FieldPlacer.tsx fieldsUrl || /api/documents/${docId}/fields useEffect loadFields fieldsUrl.*docId
from to via pattern
PdfViewer.tsx fileUrl || /api/documents/${docId}/file Document file prop and download href fileUrl.*docId
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.

<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.md

From 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" },
];
Task 1: Add onPersist + fieldsUrl props to FieldPlacer, fileUrl to PdfViewer/PdfViewerWrapper, and Templates to PortalNav 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/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 **FieldPlacer.tsx — 3 changes:**
  1. Add two optional props to FieldPlacerProps interface (after unassignedFieldIds):
  onPersist?: (fields: SignatureFieldData[]) => Promise<void> | void;
  fieldsUrl?: string;
  1. Destructure both new props in the function signature with defaults:
export function FieldPlacer({ ..., onPersist, fieldsUrl }: FieldPlacerProps) {
  1. Update the useEffect at 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]

  1. Update all 4 persistFields call sites to conditionally call onPersist instead:

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); }
  1. 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 renderFieldsonPersist 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:
  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.)

  1. Destructure onPersist, fieldsUrl, fileUrl in the function params.

  2. 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`}`
  1. Pass onPersist and fieldsUrl through to <FieldPlacer> (lines 95-107):
<FieldPlacer
  docId={docId}
  ...existing props...
  onPersist={onPersist}
  fieldsUrl={fieldsUrl}
>

PdfViewerWrapper.tsx — 3 changes:

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

  1. Destructure onPersist, fieldsUrl, fileUrl in the function params.

  2. 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" },
];
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30 - FieldPlacer.tsx contains `onPersist?: (fields: SignatureFieldData[]) => Promise | 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) All three viewer components accept optional template-mode props (onPersist, fieldsUrl, fileUrl) with backwards-compatible defaults. PortalNav shows Templates link. TypeScript compiles clean. Task 2: Create three template API routes — file, fields, ai-prepare 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 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 **Create `src/app/api/templates/[id]/file/route.ts`** — GET handler that streams the template's source PDF from `seeds/forms/`:
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 (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)
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.

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]/`

<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>
After completion, create `.planning/phases/19-template-editor-ui/19-01-SUMMARY.md`