Files
red/.planning/phases/05-pdf-fill-and-field-mapping/05-01-PLAN.md
2026-03-19 23:44:23 -06:00

16 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
05-pdf-fill-and-field-mapping 01 execute 1
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
true
DOC-04
DOC-05
DOC-06
truths artifacts key_links
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
path provides contains
teressa-copeland-homes/src/lib/db/schema.ts Extended documents table with 4 new nullable columns signatureFields
path provides exports
teressa-copeland-homes/src/lib/pdf/prepare-document.ts Server-side PDF mutation utility using @cantoo/pdf-lib
preparePdf
path provides exports
teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts GET/PUT API for signature field coordinates
GET
PUT
path provides exports
teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts POST endpoint that mutates PDF and transitions status to Sent
POST
from to via pattern
PUT /api/documents/[id]/fields documents.signatureFields (JSONB) drizzle db.update().set({ signatureFields }) db.update.*signatureFields
from to via pattern
POST /api/documents/[id]/prepare teressa-copeland-homes/src/lib/pdf/prepare-document.ts import { preparePdf } from '@/lib/pdf/prepare-document' preparePdf
from to via pattern
prepare-document.ts uploads/clients/{clientId}/{docId}_prepared.pdf atomic write via tmp → rename 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.

<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/05-pdf-fill-and-field-mapping/05-RESEARCH.md

From teressa-copeland-homes/src/lib/db/schema.ts (current state — MODIFY this file):

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):

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:
import { jsonb } from 'drizzle-orm/pg-core'; // add to existing import

// Add to documents table after filePath:
signatureFields: jsonb('signature_fields').$type<SignatureFieldData[]>(),
textFillData: jsonb('text_fill_data').$type<Record<string, string>>(),
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):

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:

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:

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<string, string>,
  sigFields: SignatureFieldData[],
): Promise<void> {
  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.

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.

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<string, string>;
    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:

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)

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/05-pdf-fill-and-field-mapping/05-01-SUMMARY.md`