feat(05-01): add preparePdf utility and fields/prepare API routes

- Installed @cantoo/pdf-lib for server-side PDF mutation
- Created src/lib/pdf/prepare-document.ts with preparePdf function using atomic tmp->rename write pattern
- form.flatten() called before drawing signature rectangles
- Created GET/PUT /api/documents/[id]/fields routes for signature field storage
- Created POST /api/documents/[id]/prepare route that calls preparePdf and transitions status to Sent
- Fixed pre-existing null check error in scripts/debug-inspect2.ts (Rule 3: blocking build)
- Build compiles successfully with 2 new API routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chandler Copeland
2026-03-19 23:54:41 -06:00
parent d67130da20
commit c81e8ea838
6 changed files with 353 additions and 3 deletions

View File

@@ -0,0 +1,39 @@
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 ?? []);
}

View File

@@ -0,0 +1,55 @@
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);
}

View File

@@ -0,0 +1,77 @@
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);
}