--- phase: 04-pdf-ingest plan: 02 type: execute wave: 2 depends_on: - 04-01 files_modified: - teressa-copeland-homes/src/app/api/forms-library/route.ts - teressa-copeland-homes/src/app/api/documents/route.ts - teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts autonomous: true requirements: - DOC-01 - DOC-02 must_haves: truths: - "GET /api/forms-library returns authenticated JSON list of form templates ordered by name" - "POST /api/documents copies seed PDF to uploads/clients/{clientId}/{uuid}.pdf and inserts a documents row" - "GET /api/documents/{id}/file streams the PDF bytes to an authenticated agent with path traversal protection" - "Unauthenticated requests to all three routes return 401" artifacts: - path: "teressa-copeland-homes/src/app/api/forms-library/route.ts" provides: "GET endpoint — authenticated forms library list" exports: ["GET"] - path: "teressa-copeland-homes/src/app/api/documents/route.ts" provides: "POST endpoint — create document from template or file picker" exports: ["POST"] - path: "teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts" provides: "GET endpoint — authenticated PDF file streaming with path traversal guard" exports: ["GET"] key_links: - from: "src/app/api/documents/route.ts" to: "uploads/clients/{clientId}/" via: "node:fs/promises copyFile + mkdir" pattern: "copyFile.*seeds/forms" - from: "src/app/api/documents/[id]/file/route.ts" to: "uploads/" via: "readFile after startsWith guard" pattern: "startsWith.*UPLOADS_BASE" --- Build the three API routes that back the forms library modal, document creation, and authenticated PDF serving. These routes are the server-side contract that the UI in Plan 03 calls. Purpose: Separating API layer into its own plan keeps Plan 03 focused on UI. Executors get clean contracts to code against. Output: Three authenticated API routes. PDF files never exposed as public static assets. @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/04-pdf-ingest/04-CONTEXT.md @.planning/phases/04-pdf-ingest/04-RESEARCH.md @.planning/phases/04-pdf-ingest/04-01-SUMMARY.md ```typescript // formTemplates table export const formTemplates = pgTable("form_templates", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), name: text("name").notNull(), filename: text("filename").notNull().unique(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); // documents table (extended) export const documents = pgTable("documents", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), name: text("name").notNull(), clientId: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }), status: documentStatusEnum("status").notNull().default("Draft"), formTemplateId: text("form_template_id").references(() => formTemplates.id), filePath: text("file_path"), sentAt: timestamp("sent_at"), createdAt: timestamp("created_at").defaultNow().notNull(), }); ``` ```typescript import { auth } from '@/lib/auth'; const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 }); ``` Task 1: GET /api/forms-library — authenticated template list teressa-copeland-homes/src/app/api/forms-library/route.ts Create `src/app/api/forms-library/route.ts`: ```typescript import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { formTemplates } from '@/lib/db/schema'; import { asc } from 'drizzle-orm'; export async function GET() { const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 }); const forms = await db .select({ id: formTemplates.id, name: formTemplates.name, filename: formTemplates.filename }) .from(formTemplates) .orderBy(asc(formTemplates.name)); return Response.json(forms); } ``` NOTE: Check the existing API routes in the project for the exact import path for `auth` and `db` — may be `@/lib/auth` or `@/auth`. Mirror exactly what Phase 3 API routes use. ```bash curl -s http://localhost:3000/api/forms-library | head -c 100 ``` Expected: `Unauthorized` (401) — confirms auth check works. (Full test requires a valid session cookie.) GET /api/forms-library returns 401 for unauthenticated requests. Returns JSON array when authenticated. Task 2: POST /api/documents and GET /api/documents/[id]/file teressa-copeland-homes/src/app/api/documents/route.ts teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts Create `src/app/api/documents/route.ts`: ```typescript import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { documents, formTemplates } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import { copyFile, mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; const SEEDS_DIR = path.join(process.cwd(), 'seeds', 'forms'); const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); export async function POST(req: Request) { const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 }); const contentType = req.headers.get('content-type') ?? ''; let clientId: string; let name: string; let formTemplateId: string | undefined; let fileBuffer: ArrayBuffer | undefined; let originalFilename: string | undefined; if (contentType.includes('multipart/form-data')) { // File picker path — custom PDF upload const formData = await req.formData(); clientId = formData.get('clientId') as string; name = formData.get('name') as string; const file = formData.get('file') as File; fileBuffer = await file.arrayBuffer(); originalFilename = file.name; } else { // Library path — copy from seed template const body = await req.json(); clientId = body.clientId; name = body.name; formTemplateId = body.formTemplateId; } if (!clientId || !name) { return Response.json({ error: 'clientId and name are required' }, { status: 400 }); } const docId = crypto.randomUUID(); const destDir = path.join(UPLOADS_DIR, 'clients', clientId); const destPath = path.join(destDir, `${docId}.pdf`); const relPath = `clients/${clientId}/${docId}.pdf`; // Path traversal guard if (!destPath.startsWith(UPLOADS_DIR)) { return new Response('Forbidden', { status: 403 }); } await mkdir(destDir, { recursive: true }); if (fileBuffer !== undefined) { // Custom upload await writeFile(destPath, Buffer.from(fileBuffer)); } else { // Template copy const template = await db.query.formTemplates.findFirst({ where: eq(formTemplates.id, formTemplateId!), }); if (!template) return Response.json({ error: 'Template not found' }, { status: 404 }); const srcPath = path.join(SEEDS_DIR, template.filename); await copyFile(srcPath, destPath); } const [doc] = await db.insert(documents).values({ id: docId, clientId, name, formTemplateId: formTemplateId ?? null, filePath: relPath, status: 'Draft', }).returning(); return Response.json(doc, { status: 201 }); } ``` Create `src/app/api/documents/[id]/file/route.ts`: ```typescript import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { documents } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; const UPLOADS_BASE = path.join(process.cwd(), 'uploads'); 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 doc = await db.query.documents.findFirst({ where: eq(documents.id, id), }); if (!doc || !doc.filePath) return new Response('Not found', { status: 404 }); const filePath = path.join(UPLOADS_BASE, doc.filePath); // Path traversal guard — critical security check if (!filePath.startsWith(UPLOADS_BASE)) { return new Response('Forbidden', { status: 403 }); } try { const buffer = await readFile(filePath); return new Response(buffer, { headers: { 'Content-Type': 'application/pdf' }, }); } catch { return new Response('File not found', { status: 404 }); } } ``` NOTE: In Next.js 15 App Router, `params` is a Promise — always `await params` before destructuring. This matches the pattern in Phase 3's dynamic routes. NOTE: The `documents.status` field expects the enum literal `'Draft'` — use that exact casing (capital D) to match the existing `documentStatusEnum` definition. ```bash curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/documents -X POST -H "Content-Type: application/json" -d '{"clientId":"x","name":"test"}' && echo "" && curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/documents/fake-id/file && echo "" ``` Expected: `401` for POST (unauthed), `401` for GET file (unauthed). POST /api/documents creates document records and copies/writes PDF files. GET /api/documents/[id]/file streams PDFs with auth + path traversal guard. Both return 401 for unauthenticated requests. - GET /api/forms-library → 401 unauthenticated - POST /api/documents → 401 unauthenticated - GET /api/documents/[id]/file → 401 unauthenticated - uploads/ directory is NOT under public/ (files not accessible without auth) - No absolute paths stored in DB (relPath uses "clients/{id}/{uuid}.pdf" format) Three authenticated API routes deployed and responding. PDFs served only through authenticated routes. Path traversal protection in place on file serving route. After completion, create `.planning/phases/04-pdf-ingest/04-02-SUMMARY.md`