--- phase: 05-pdf-fill-and-field-mapping plan: 01 type: execute wave: 1 depends_on: [] files_modified: - teressa-copeland-homes/src/lib/db/schema.ts - teressa-copeland-homes/src/lib/pdf/prepare-document.ts - teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts - teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts autonomous: true requirements: [DOC-04, DOC-05, DOC-06] must_haves: truths: - "Schema has signatureFields (JSONB), textFillData (JSONB), assignedClientId (text), and preparedFilePath (text) columns on documents table" - "Migration 0003 is applied — columns exist in the local PostgreSQL database" - "GET /api/documents/[id]/fields returns stored signature fields as JSON array" - "PUT /api/documents/[id]/fields stores a SignatureFieldData[] array to the signatureFields column" - "POST /api/documents/[id]/prepare fills AcroForm fields (or draws text), burns signature rectangles, writes prepared PDF to uploads/clients/{clientId}/{docId}_prepared.pdf, and transitions document status to Sent" artifacts: - path: "teressa-copeland-homes/src/lib/db/schema.ts" provides: "Extended documents table with 4 new nullable columns" contains: "signatureFields" - path: "teressa-copeland-homes/src/lib/pdf/prepare-document.ts" provides: "Server-side PDF mutation utility using @cantoo/pdf-lib" exports: ["preparePdf"] - path: "teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts" provides: "GET/PUT API for signature field coordinates" exports: ["GET", "PUT"] - path: "teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts" provides: "POST endpoint that mutates PDF and transitions status to Sent" exports: ["POST"] key_links: - from: "PUT /api/documents/[id]/fields" to: "documents.signatureFields (JSONB)" via: "drizzle db.update().set({ signatureFields })" pattern: "db\\.update.*signatureFields" - from: "POST /api/documents/[id]/prepare" to: "teressa-copeland-homes/src/lib/pdf/prepare-document.ts" via: "import { preparePdf } from '@/lib/pdf/prepare-document'" pattern: "preparePdf" - from: "prepare-document.ts" to: "uploads/clients/{clientId}/{docId}_prepared.pdf" via: "atomic write via tmp → rename" pattern: "rename.*tmp" --- Extend the database schema with four new columns on the documents table, generate and apply the Drizzle migration, create the server-side PDF preparation utility using @cantoo/pdf-lib, and add two new API route files for signature field storage and document preparation. Purpose: Establish the data contracts and server logic that the field-placer UI (Plan 02) and text-fill UI (Plan 03) will consume. Everything in Phase 5 builds on these server endpoints. Output: Migration 0003 applied, two new API routes live, @cantoo/pdf-lib utility with atomic write behavior. @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-pdf-fill-and-field-mapping/05-RESEARCH.md From teressa-copeland-homes/src/lib/db/schema.ts (current state — MODIFY this file): ```typescript import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; export const documentStatusEnum = pgEnum("document_status", ["Draft", "Sent", "Viewed", "Signed"]); 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"), sentAt: timestamp("sent_at"), createdAt: timestamp("created_at").defaultNow().notNull(), formTemplateId: text("form_template_id").references(() => formTemplates.id), filePath: text("file_path"), // ADD: signatureFields, textFillData, assignedClientId, preparedFilePath }); ``` From teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts (auth pattern to mirror): ```typescript import { auth } from '@/lib/auth'; // params is a Promise in Next.js 15 — always await before destructuring 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; // ... } ``` File path patterns from Phase 4 decisions: - UPLOADS_DIR = path.join(process.cwd(), 'uploads') - DB stores relative paths: 'clients/{clientId}/{docId}.pdf' - Absolute path at read time: path.join(UPLOADS_DIR, relPath) - New prepared file: 'clients/{clientId}/{docId}_prepared.pdf' Task 1: Extend schema + generate and apply migration 0003 teressa-copeland-homes/src/lib/db/schema.ts teressa-copeland-homes/drizzle/0003_*.sql (generated) teressa-copeland-homes/drizzle/meta/_journal.json (updated) Add four nullable columns to the documents table in schema.ts. Import `jsonb` from 'drizzle-orm/pg-core'. Add these columns to the documents pgTable definition — place them after the existing `filePath` column: ```typescript import { jsonb } from 'drizzle-orm/pg-core'; // add to existing import // Add to documents table after filePath: signatureFields: jsonb('signature_fields').$type(), textFillData: jsonb('text_fill_data').$type>(), assignedClientId: text('assigned_client_id'), preparedFilePath: text('prepared_file_path'), ``` Also export this TypeScript interface from schema.ts (add near the top of the file, before the table definitions): ```typescript export interface SignatureFieldData { id: string; page: number; // 1-indexed x: number; // PDF user space, bottom-left origin, points y: number; // PDF user space, bottom-left origin, points width: number; // PDF points (default: 144 — 2 inches) height: number; // PDF points (default: 36 — 0.5 inches) } ``` Then run in the teressa-copeland-homes directory: ```bash cd /Users/ccopeland/temp/red/teressa-copeland-homes npm run db:generate npm run db:migrate ``` The migration should add 4 columns (ALTER TABLE documents ADD COLUMN ...) — confirm the generated SQL looks correct before applying. The migration command will prompt if needed; migration applies via drizzle-kit migrate (not push — schema is controlled via migrations in this project). cd /Users/ccopeland/temp/red/teressa-copeland-homes && node -e "const { db } = require('./src/lib/db'); db.execute('SELECT signature_fields, text_fill_data, assigned_client_id, prepared_file_path FROM documents LIMIT 0').then(() => { console.log('PASS: columns exist'); process.exit(0); }).catch(e => { console.error('FAIL:', e.message); process.exit(1); });" 2>/dev/null || echo "Verify via: ls drizzle/0003_*.sql should show generated file" schema.ts has all 4 new nullable columns typed correctly; drizzle/0003_*.sql exists; npm run db:migrate completes without error Task 2: Create pdf prepare-document utility + API routes for fields and prepare teressa-copeland-homes/src/lib/pdf/prepare-document.ts teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts **Step A — Install @cantoo/pdf-lib** (do NOT install `pdf-lib` — they conflict): ```bash cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm install @cantoo/pdf-lib ``` **Step B — Create teressa-copeland-homes/src/lib/pdf/prepare-document.ts:** ```typescript import { PDFDocument, StandardFonts, rgb } from '@cantoo/pdf-lib'; import { readFile, writeFile, rename } from 'node:fs/promises'; import type { SignatureFieldData } from '@/lib/db/schema'; /** * Fills AcroForm text fields and draws signature rectangles on a PDF. * Uses atomic write: write to tmp path, verify %PDF header, rename to final path. * * @param srcPath - Absolute path to original PDF * @param destPath - Absolute path to write prepared PDF (e.g. {docId}_prepared.pdf) * @param textFields - Key/value map: { fieldNameOrLabel: value } (agent-provided) * @param sigFields - Signature field array from SignatureFieldData[] */ export async function preparePdf( srcPath: string, destPath: string, textFields: Record, sigFields: SignatureFieldData[], ): Promise { const pdfBytes = await readFile(srcPath); const pdfDoc = await PDFDocument.load(pdfBytes); const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); const pages = pdfDoc.getPages(); // Strategy A: fill existing AcroForm fields by name // form.flatten() MUST happen before drawing rectangles try { const form = pdfDoc.getForm(); for (const [fieldName, value] of Object.entries(textFields)) { try { form.getTextField(fieldName).setText(value); } catch { // Field name not found in AcroForm — silently skip } } form.flatten(); } catch { // No AcroForm in this PDF — skip to rectangle drawing } // Draw signature field placeholders (blue rectangle + "Sign Here" label) for (const field of sigFields) { const page = pages[field.page - 1]; // page is 1-indexed if (!page) continue; page.drawRectangle({ x: field.x, y: field.y, width: field.width, height: field.height, borderColor: rgb(0.15, 0.39, 0.92), borderWidth: 1.5, color: rgb(0.90, 0.94, 0.99), }); page.drawText('Sign Here', { x: field.x + 4, y: field.y + 4, size: 8, font: helvetica, color: rgb(0.15, 0.39, 0.92), }); } const modifiedBytes = await pdfDoc.save(); // Atomic write: tmp → rename (prevents partial writes from corrupting the only copy) const tmpPath = `${destPath}.tmp`; await writeFile(tmpPath, modifiedBytes); // Verify output is a valid PDF (check magic bytes) const magic = modifiedBytes.slice(0, 4); const header = Buffer.from(magic).toString('ascii'); if (!header.startsWith('%PDF')) { throw new Error('preparePdf: output is not a valid PDF (magic bytes check failed)'); } await rename(tmpPath, destPath); } ``` **Step C — Create teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts:** GET returns the stored signatureFields array. PUT accepts a SignatureFieldData[] body and stores it. ```typescript import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { documents } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import type { SignatureFieldData } from '@/lib/db/schema'; 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) return Response.json({ error: 'Not found' }, { status: 404 }); return Response.json(doc.signatureFields ?? []); } export async function PUT( req: Request, { params }: { params: Promise<{ id: string }> } ) { const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 }); const { id } = await params; const fields: SignatureFieldData[] = await req.json(); const [updated] = await db .update(documents) .set({ signatureFields: fields }) .where(eq(documents.id, id)) .returning(); if (!updated) return Response.json({ error: 'Not found' }, { status: 404 }); return Response.json(updated.signatureFields ?? []); } ``` **Step D — Create teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts:** POST: loads the document, resolves the src PDF, calls preparePdf, stores the preparedFilePath, updates textFillData, assignedClientId, status to 'Sent', and sets sentAt. ```typescript import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { documents } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import { preparePdf } from '@/lib/pdf/prepare-document'; 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) return new Response('Unauthorized', { status: 401 }); const { id } = await params; const body = await req.json() as { textFillData?: Record; assignedClientId?: string; }; const doc = await db.query.documents.findFirst({ where: eq(documents.id, id) }); if (!doc) return Response.json({ error: 'Not found' }, { status: 404 }); if (!doc.filePath) return Response.json({ error: 'Document has no PDF file' }, { status: 422 }); const srcPath = path.join(UPLOADS_DIR, doc.filePath); // Prepared variant stored next to original with _prepared suffix const preparedRelPath = doc.filePath.replace(/\.pdf$/, '_prepared.pdf'); const destPath = path.join(UPLOADS_DIR, preparedRelPath); // Path traversal guard if (!destPath.startsWith(UPLOADS_DIR)) { return new Response('Forbidden', { status: 403 }); } const sigFields = (doc.signatureFields as import('@/lib/db/schema').SignatureFieldData[]) ?? []; const textFields = body.textFillData ?? {}; await preparePdf(srcPath, destPath, textFields, sigFields); const [updated] = await db .update(documents) .set({ preparedFilePath: preparedRelPath, textFillData: body.textFillData ?? null, assignedClientId: body.assignedClientId ?? doc.assignedClientId ?? null, status: 'Sent', sentAt: new Date(), }) .where(eq(documents.id, id)) .returning(); return Response.json(updated); } ``` **Verify build compiles cleanly after creating all files:** ```bash cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -20 ``` cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error|Error|FAILED|compiled successfully)" | head -10 - @cantoo/pdf-lib in package.json dependencies - src/lib/pdf/prepare-document.ts exports preparePdf function - GET /api/documents/[id]/fields returns 401 unauthenticated (verifiable via curl) - PUT /api/documents/[id]/fields returns 401 unauthenticated - POST /api/documents/[id]/prepare returns 401 unauthenticated - npm run build completes without TypeScript errors After both tasks complete: 1. `ls /Users/ccopeland/temp/red/teressa-copeland-homes/drizzle/0003_*.sql` — migration file exists 2. `ls /Users/ccopeland/temp/red/teressa-copeland-homes/src/lib/pdf/prepare-document.ts` — utility exists 3. `ls /Users/ccopeland/temp/red/teressa-copeland-homes/src/app/api/documents/[docId]/fields/route.ts` — route exists (note: directory name may use [id] to match existing pattern) 4. `npm run build` — clean compile 5. `curl -s http://localhost:3000/api/documents/test-id/fields` — returns "Unauthorized" (server must be running) - Migration 0003 applied: documents table has 4 new nullable columns (signature_fields JSONB, text_fill_data JSONB, assigned_client_id TEXT, prepared_file_path TEXT) - SignatureFieldData interface exported from schema.ts with id, page, x, y, width, height fields - @cantoo/pdf-lib installed (NOT the original pdf-lib — they conflict) - preparePdf utility uses atomic tmp→rename write pattern - form.flatten() called BEFORE drawing signature rectangles - GET/PUT /api/documents/[id]/fields returns 401 without auth, correct JSON with auth - POST /api/documents/[id]/prepare returns 401 without auth; with auth loads doc, calls preparePdf, transitions status to Sent - npm run build compiles without errors After completion, create `.planning/phases/05-pdf-fill-and-field-mapping/05-01-SUMMARY.md`