From c81e8ea838b24900e31ed78d16b849da5ad06f4d Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Thu, 19 Mar 2026 23:54:41 -0600 Subject: [PATCH] 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 --- teressa-copeland-homes/package-lock.json | 180 +++++++++++++++++- teressa-copeland-homes/package.json | 3 + .../scripts/debug-inspect2.ts | 2 +- .../app/api/documents/[id]/fields/route.ts | 39 ++++ .../app/api/documents/[id]/prepare/route.ts | 55 ++++++ .../src/lib/pdf/prepare-document.ts | 77 ++++++++ 6 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts create mode 100644 teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts create mode 100644 teressa-copeland-homes/src/lib/pdf/prepare-document.ts diff --git a/teressa-copeland-homes/package-lock.json b/teressa-copeland-homes/package-lock.json index 299531f..6286b21 100644 --- a/teressa-copeland-homes/package-lock.json +++ b/teressa-copeland-homes/package-lock.json @@ -8,7 +8,9 @@ "name": "teressa-copeland-homes", "version": "0.1.0", "dependencies": { + "@cantoo/pdf-lib": "^2.6.3", "@vercel/blob": "^2.3.1", + "adm-zip": "^0.5.16", "bcryptjs": "^3.0.3", "drizzle-orm": "^0.45.1", "lucide-react": "^0.577.0", @@ -23,6 +25,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/adm-zip": "^0.5.8", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/nodemailer": "^7.0.11", @@ -32,6 +35,7 @@ "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.0", + "playwright": "^1.58.2", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5" @@ -319,6 +323,21 @@ "node": ">=6.9.0" } }, + "node_modules/@cantoo/pdf-lib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.6.3.tgz", + "integrity": "sha512-oscThzfl9M+/8lWM4Ia5ZaEhKx8MffqSm05hDm5nDMcOTvGPA4j/lGLxYj9NVZoJqvgGMkoSMvKfm/acsoaAPA==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "color": "^4.2.3", + "crypto-js": "^4.2.0", + "node-html-better-parser": ">=1.4.0", + "pako": "^1.0.11", + "tslib": ">=2" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -2439,6 +2458,24 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2737,6 +2774,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -3421,6 +3468,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -3898,11 +3954,23 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3915,9 +3983,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3947,6 +4024,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5439,6 +5522,22 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5509,6 +5608,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -6704,6 +6809,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-html-better-parser": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz", + "integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==", + "license": "MIT", + "dependencies": { + "html-entities": "^2.3.2" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -6920,6 +7034,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7028,6 +7148,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7662,6 +7829,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/teressa-copeland-homes/package.json b/teressa-copeland-homes/package.json index 9715653..f729037 100644 --- a/teressa-copeland-homes/package.json +++ b/teressa-copeland-homes/package.json @@ -15,7 +15,9 @@ "scrape:forms": "DOTENV_CONFIG_PATH=.env.local npx tsx scripts/scrape-skyslope-forms.ts" }, "dependencies": { + "@cantoo/pdf-lib": "^2.6.3", "@vercel/blob": "^2.3.1", + "adm-zip": "^0.5.16", "bcryptjs": "^3.0.3", "drizzle-orm": "^0.45.1", "lucide-react": "^0.577.0", @@ -30,6 +32,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/adm-zip": "^0.5.8", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/nodemailer": "^7.0.11", diff --git a/teressa-copeland-homes/scripts/debug-inspect2.ts b/teressa-copeland-homes/scripts/debug-inspect2.ts index 6a48703..943f9a2 100644 --- a/teressa-copeland-homes/scripts/debug-inspect2.ts +++ b/teressa-copeland-homes/scripts/debug-inspect2.ts @@ -19,7 +19,7 @@ config({ path: path.resolve(process.cwd(), '.env.local') }); for (const el of loginLinks) { const text = await el.textContent().catch(() => ''); const href = await el.getAttribute('href').catch(() => ''); - if (/login|sign.?in|log.?in|get started|access/i.test(text + href)) { + if (/login|sign.?in|log.?in|get started|access/i.test((text ?? '') + (href ?? ''))) { console.log('Found login element:', text?.trim().slice(0,60), href?.slice(0,80)); } } diff --git a/teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts b/teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts new file mode 100644 index 0000000..a710477 --- /dev/null +++ b/teressa-copeland-homes/src/app/api/documents/[id]/fields/route.ts @@ -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 ?? []); +} diff --git a/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts b/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts new file mode 100644 index 0000000..2141d97 --- /dev/null +++ b/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts @@ -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; + 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); +} diff --git a/teressa-copeland-homes/src/lib/pdf/prepare-document.ts b/teressa-copeland-homes/src/lib/pdf/prepare-document.ts new file mode 100644 index 0000000..d36abf0 --- /dev/null +++ b/teressa-copeland-homes/src/lib/pdf/prepare-document.ts @@ -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, + 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); +}