feat(10-02): fix POST handler — signable field filter and date stamping at sign time
- Add getFieldType to schema import - Add PDFDocument, StandardFonts, rgb from @cantoo/pdf-lib for date stamping - Add readFile, writeFile, unlink from node:fs/promises - Hoist const now = new Date() to before step 8 (shared for date stamp + DB update) - Step 8a: stamp signing date onto date fields in prepared PDF before embed - Step 8b: filter signableFields to client-signature and initials only - signaturesWithCoords now maps only signable fields (no 500 on text/checkbox/date) - Update embedSignatureInPdf call to use dateStampedPath - Fire-and-forget cleanup of temporary .datestamped.tmp file after embed
This commit is contained in:
@@ -3,9 +3,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { verifySigningToken } from '@/lib/signing/token';
|
import { verifySigningToken } from '@/lib/signing/token';
|
||||||
import { logAuditEvent } from '@/lib/signing/audit';
|
import { logAuditEvent } from '@/lib/signing/audit';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { signingTokens, documents, clients, isClientVisibleField } from '@/lib/db/schema';
|
import { signingTokens, documents, clients, isClientVisibleField, getFieldType } from '@/lib/db/schema';
|
||||||
import { eq, isNull, and } from 'drizzle-orm';
|
import { eq, isNull, and } from 'drizzle-orm';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
||||||
|
import { PDFDocument, StandardFonts, rgb } from '@cantoo/pdf-lib';
|
||||||
import { embedSignatureInPdf } from '@/lib/signing/embed-signature';
|
import { embedSignatureInPdf } from '@/lib/signing/embed-signature';
|
||||||
import { sendAgentNotificationEmail } from '@/lib/signing/signing-mailer';
|
import { sendAgentNotificationEmail } from '@/lib/signing/signing-mailer';
|
||||||
|
|
||||||
@@ -168,9 +170,54 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'forbidden' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Merge client-supplied dataURLs with server-stored field coordinates
|
// Capture signing timestamp here — reused for date stamping (8a) and DB update (11)
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 8a. Stamp date text at each 'date' field coordinate (signing date = now, captured server-side)
|
||||||
|
const dateFields = (doc.signatureFields ?? []).filter(
|
||||||
|
(f) => getFieldType(f) === 'date'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only load and modify PDF if there are date fields to stamp
|
||||||
|
let dateStampedPath = preparedAbsPath;
|
||||||
|
if (dateFields.length > 0) {
|
||||||
|
const pdfBytes = await readFile(preparedAbsPath);
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
|
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const signingDateStr = now.toLocaleDateString('en-US', {
|
||||||
|
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||||
|
});
|
||||||
|
for (const field of dateFields) {
|
||||||
|
const page = pages[field.page - 1];
|
||||||
|
if (!page) continue;
|
||||||
|
// Overwrite the amber placeholder rectangle with white background + date text
|
||||||
|
page.drawRectangle({
|
||||||
|
x: field.x, y: field.y, width: field.width, height: field.height,
|
||||||
|
borderColor: rgb(0.39, 0.45, 0.55), borderWidth: 0.5,
|
||||||
|
color: rgb(1.0, 1.0, 1.0),
|
||||||
|
});
|
||||||
|
page.drawText(signingDateStr, {
|
||||||
|
x: field.x + 4,
|
||||||
|
y: field.y + field.height / 2 - 4, // vertically center
|
||||||
|
size: 10, font: helvetica, color: rgb(0.05, 0.05, 0.55),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const stampedBytes = await pdfDoc.save();
|
||||||
|
// Write to a temporary date-stamped path; embedSignatureInPdf reads from this path
|
||||||
|
dateStampedPath = `${preparedAbsPath}.datestamped.tmp`;
|
||||||
|
await writeFile(dateStampedPath, stampedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8b. Build signaturesWithCoords for client-signable fields only (client-signature + initials)
|
||||||
|
// text/checkbox/date are embedded at prepare time; the client was never shown these as interactive fields
|
||||||
// CRITICAL: x/y/width/height come ONLY from the DB — never from the request body
|
// CRITICAL: x/y/width/height come ONLY from the DB — never from the request body
|
||||||
const signaturesWithCoords = (doc.signatureFields ?? []).map((field) => {
|
const signableFields = (doc.signatureFields ?? []).filter((f) => {
|
||||||
|
const t = getFieldType(f);
|
||||||
|
return t === 'client-signature' || t === 'initials';
|
||||||
|
});
|
||||||
|
|
||||||
|
const signaturesWithCoords = signableFields.map((field) => {
|
||||||
const clientSig = signatures.find((s) => s.fieldId === field.id);
|
const clientSig = signatures.find((s) => s.fieldId === field.id);
|
||||||
if (!clientSig) {
|
if (!clientSig) {
|
||||||
throw new Error(`Missing signature for field ${field.id}`);
|
throw new Error(`Missing signature for field ${field.id}`);
|
||||||
@@ -189,12 +236,17 @@ export async function POST(
|
|||||||
// 9. Embed signatures into PDF — returns SHA-256 hex hash
|
// 9. Embed signatures into PDF — returns SHA-256 hex hash
|
||||||
let pdfHash: string;
|
let pdfHash: string;
|
||||||
try {
|
try {
|
||||||
pdfHash = await embedSignatureInPdf(preparedAbsPath, signedAbsPath, signaturesWithCoords);
|
pdfHash = await embedSignatureInPdf(dateStampedPath, signedAbsPath, signaturesWithCoords);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[sign/POST] embedSignatureInPdf failed:', err);
|
console.error('[sign/POST] embedSignatureInPdf failed:', err);
|
||||||
return NextResponse.json({ error: 'pdf-embed-failed' }, { status: 500 });
|
return NextResponse.json({ error: 'pdf-embed-failed' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up temporary date-stamped file if it was created
|
||||||
|
if (dateStampedPath !== preparedAbsPath) {
|
||||||
|
unlink(dateStampedPath).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// 10. Log pdf_hash_computed audit event
|
// 10. Log pdf_hash_computed audit event
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
documentId: payload.documentId,
|
documentId: payload.documentId,
|
||||||
@@ -205,7 +257,6 @@ export async function POST(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 11. Update documents table: status, signedAt, signedFilePath, pdfHash
|
// 11. Update documents table: status, signedAt, signedFilePath, pdfHash
|
||||||
const now = new Date();
|
|
||||||
await db
|
await db
|
||||||
.update(documents)
|
.update(documents)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
Reference in New Issue
Block a user