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:
@@ -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 ?? []);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
77
teressa-copeland-homes/src/lib/pdf/prepare-document.ts
Normal file
77
teressa-copeland-homes/src/lib/pdf/prepare-document.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user