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,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);
}