4 plans in 4 sequential waves covering: - Plan 01 (TDD): openai install, extract-text.ts, field-placement.ts, aiCoordsToPagePdfSpace unit test - Plan 02: POST /api/documents/[id]/ai-prepare route with all guards - Plan 03: UI wiring — aiPlacementKey in FieldPlacer, AI Auto-place button in PreparePanel - Plan 04: Unit test gate + human E2E verification checkpoint Satisfies AI-01, AI-02. Completes v1.1 Smart Document Preparation milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 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 | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 13-ai-field-placement-and-pre-fill | 02 | execute | 2 |
|
|
true |
|
|
Purpose: This is the server-side orchestration layer. The AI utility modules (Plan 01) provide the logic; this route wires them to the document DB record and returns the result to the client.
Output: src/app/api/documents/[id]/ai-prepare/route.ts
<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/13-ai-field-placement-and-pre-fill/13-RESEARCH.md @.planning/phases/13-ai-field-placement-and-pre-fill/13-01-SUMMARY.mdRead next.config.ts and check for Next.js guide in node_modules/next/dist/docs/ before writing route code.
From teressa-copeland-homes/src/lib/db/schema.ts:
export interface SignatureFieldData {
id: string;
page: number;
x: number; y: number; width: number; height: number;
type?: SignatureFieldType;
}
export const documents = pgTable("documents", {
id: text("id").primaryKey(),
clientId: text("client_id").notNull(),
status: documentStatusEnum("status").notNull().default("Draft"),
filePath: text("file_path"),
signatureFields: jsonb("signature_fields").$type<SignatureFieldData[]>(),
textFillData: jsonb("text_fill_data").$type<Record<string, string>>(),
// ...
});
export const clients = pgTable("clients", {
id: text("id").primaryKey(),
name: text("name").notNull(),
propertyAddress: text("property_address"),
// ...
});
export const documentsRelations = relations(documents, ({ one }) => ({
client: one(clients, { fields: [documents.clientId], references: [clients.id] }),
}));
From existing prepare route pattern (src/app/api/documents/[id]/prepare/route.ts):
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents, users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import path from 'node:path';
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
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 });
const { id } = await params;
const doc = await db.query.documents.findFirst({
where: eq(documents.id, id),
with: { client: true }, // use this to get client.name and client.propertyAddress
});
if (!doc) return Response.json({ error: 'Not found' }, { status: 404 });
// ...
}
From Plan 01 (13-01-SUMMARY.md):
// src/lib/ai/extract-text.ts
export async function extractPdfText(filePath: string): Promise<PageText[]>
export interface PageText { page: number; text: string; width: number; height: number; }
// src/lib/ai/field-placement.ts
export async function classifyFieldsWithAI(
pageTexts: PageText[],
client: { name: string | null; propertyAddress: string | null } | null
): Promise<{ fields: SignatureFieldData[], textFillData: Record<string, string> }>
Implement:
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import path from 'node:path';
import { extractPdfText } from '@/lib/ai/extract-text';
import { classifyFieldsWithAI } from '@/lib/ai/field-placement';
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
// 1. Auth guard — same pattern as prepare/route.ts
const session = await auth();
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
// 2. OPENAI_API_KEY guard — fail fast with 503 if not configured
if (!process.env.OPENAI_API_KEY) {
return Response.json(
{ error: 'OPENAI_API_KEY not configured. Add it to .env.local.' },
{ status: 503 }
);
}
// 3. Load document with client relation
const { id } = await params;
const doc = await db.query.documents.findFirst({
where: eq(documents.id, id),
with: { client: true },
});
if (!doc) return Response.json({ error: 'Not found' }, { status: 404 });
if (!doc.filePath) return Response.json({ error: 'Document has no PDF file' }, { status: 422 });
// 4. Only Draft documents can be AI-prepared (same principle as prepare route)
if (doc.status !== 'Draft') {
return Response.json({ error: 'Document is locked — only Draft documents can use AI auto-place' }, { status: 403 });
}
// 5. Path traversal guard
const filePath = path.join(UPLOADS_DIR, doc.filePath);
if (!filePath.startsWith(UPLOADS_DIR)) {
return new Response('Forbidden', { status: 403 });
}
// 6. Extract text from PDF (server-side, pdfjs-dist legacy build)
const pageTexts = await extractPdfText(filePath);
// 7. Classify fields with GPT-4o-mini + coordinate conversion
const client = (doc as typeof doc & { client: { name: string; propertyAddress: string | null } | null }).client;
const { fields, textFillData } = await classifyFieldsWithAI(
pageTexts,
client ? { name: client.name, propertyAddress: client.propertyAddress ?? null } : null,
);
// 8. Write fields to DB (replaces existing signatureFields — agent reviews after)
// Do NOT change document status — stays Draft so agent can review and adjust
const [updated] = await db
.update(documents)
.set({ signatureFields: fields })
.where(eq(documents.id, id))
.returning();
// 9. Return fields and textFillData for client state update
return Response.json({
fields: updated.signatureFields ?? [],
textFillData,
});
}
Key decisions to honor (from STATE.md and RESEARCH.md):
- Do NOT use zodResponseFormat (broken with Zod v4) — this route delegates to classifyFieldsWithAI which uses manual json_schema
- Do NOT change document status to anything other than Draft — only the prepare route moves status
- textFillData is keyed by field UUID (classifyFieldsWithAI handles this — see Plan 01)
doc.clientis available via Drizzle relation — usewith: { client: true }in the query
Error handling: wrap the extractPdfText + classifyFieldsWithAI calls in try/catch, return 500 with the error message if they throw (AI errors, PDF read errors, etc.).
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | grep -E "ai-prepare|error" | head -20
- src/app/api/documents/[id]/ai-prepare/route.ts exists and exports POST
- TypeScript compiles without errors for this file
- Route structure matches Next.js App Router pattern (params as Promise)
- All guards present: auth, OPENAI_API_KEY, not-found, no-filePath, non-Draft
- Fields written to DB, document status stays Draft
- Returns { fields: SignatureFieldData[], textFillData: Record<string, string> }
<success_criteria>
POST /api/documents/[id]/ai-preparereturns 401 for unauthenticated requests- Returns 503 when OPENAI_API_KEY is absent from environment
- Returns 403 for non-Draft documents
- Returns
{ fields, textFillData }on success, with fields written to DB - TypeScript compiles clean </success_criteria>