From 275565c933e261281aee89bb4669c2dfe375d605 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Mon, 6 Apr 2026 13:10:02 -0600 Subject: [PATCH] =?UTF-8?q?feat(19-01):=20create=20template=20API=20routes?= =?UTF-8?q?=20=E2=80=94=20file,=20fields,=20ai-prepare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/templates/[id]/file: streams form PDF from seeds/forms/ with path traversal guard - GET /api/templates/[id]/fields: returns signatureFields array (or []) from documentTemplates - POST /api/templates/[id]/ai-prepare: runs AI field placement with null client context, writes to DB - All routes: auth guard + isNull(archivedAt) soft-delete filter consistent with Phase 18 --- .../api/templates/[id]/ai-prepare/route.ts | 50 +++++++++++++++++++ .../app/api/templates/[id]/fields/route.ts | 20 ++++++++ .../src/app/api/templates/[id]/file/route.ts | 39 +++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts create mode 100644 teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts create mode 100644 teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts diff --git a/teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts b/teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts new file mode 100644 index 0000000..2f60c25 --- /dev/null +++ b/teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts @@ -0,0 +1,50 @@ +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 }); + } +} diff --git a/teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts b/teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts new file mode 100644 index 0000000..b5fae2b --- /dev/null +++ b/teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts @@ -0,0 +1,20 @@ +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 ?? []); +} diff --git a/teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts b/teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts new file mode 100644 index 0000000..a779027 --- /dev/null +++ b/teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts @@ -0,0 +1,39 @@ +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 }); + } +}