import { headers } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { verifySigningToken } from '@/lib/signing/token'; import { logAuditEvent } from '@/lib/signing/audit'; import { db } from '@/lib/db'; import { signingTokens, documents, clients, isClientVisibleField, getFieldType } from '@/lib/db/schema'; import { eq, isNull, and } from 'drizzle-orm'; 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 { sendAgentNotificationEmail } from '@/lib/signing/signing-mailer'; const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); // Public route — no auth session required export async function GET( _req: NextRequest, { params }: { params: Promise<{ token: string }> } ) { const { token } = await params; // Extract IP and user-agent for audit logging const hdrs = await headers(); const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() ?? hdrs.get('x-real-ip') ?? 'unknown'; const ua = hdrs.get('user-agent') ?? 'unknown'; // 1. Verify JWT let payload: { documentId: string; jti: string; exp: number }; try { payload = await verifySigningToken(token); } catch (err: unknown) { // Check if it's a JWT expiry error const errName = err instanceof Error ? err.constructor.name : ''; const errMessage = err instanceof Error ? err.message : ''; if (errName === 'JWTExpired' || errMessage.includes('expired')) { return NextResponse.json({ status: 'expired' }, { status: 200 }); } return NextResponse.json({ status: 'invalid' }, { status: 200 }); } // 2. Look up jti in signingTokens table const tokenRow = await db.query.signingTokens.findFirst({ where: eq(signingTokens.jti, payload.jti), }); if (!tokenRow) { return NextResponse.json({ status: 'invalid' }, { status: 200 }); } // 3. Check one-time use if (tokenRow.usedAt !== null) { return NextResponse.json( { status: 'used', signedAt: tokenRow.usedAt.toISOString() }, { status: 200 } ); } // 4. Fetch document const doc = await db.query.documents.findFirst({ where: eq(documents.id, payload.documentId), columns: { id: true, name: true, signatureFields: true, preparedFilePath: true, }, }); if (!doc || !doc.preparedFilePath) { return NextResponse.json({ status: 'invalid' }, { status: 200 }); } // 5 & 6. Log audit events + update status to Viewed await Promise.all([ logAuditEvent({ documentId: payload.documentId, eventType: 'link_opened', ipAddress: ip, userAgent: ua }), logAuditEvent({ documentId: payload.documentId, eventType: 'document_viewed', ipAddress: ip, userAgent: ua }), db.update(documents).set({ status: 'Viewed' }).where(eq(documents.id, payload.documentId)), ]); // 7. Return pending state with document data return NextResponse.json({ status: 'pending', document: { id: doc.id, name: doc.name, signatureFields: (doc.signatureFields ?? []).filter(isClientVisibleField), preparedFilePath: doc.preparedFilePath, }, expiresAt: new Date(payload.exp * 1000).toISOString(), }); } // POST /api/sign/[token] — atomic signing submission // Body: { signatures: Array<{ fieldId: string; dataURL: string }> } // NEVER reads x/y/width/height from client — always uses server-stored signatureFields export async function POST( req: NextRequest, { params }: { params: Promise<{ token: string }> } ) { const { token } = await params; // 1. Parse body const { signatures } = (await req.json()) as { signatures: Array<{ fieldId: string; dataURL: string }>; }; // 2. Verify JWT let payload: { documentId: string; jti: string; exp: number }; try { payload = await verifySigningToken(token); } catch { return NextResponse.json({ error: 'invalid-token' }, { status: 401 }); } // Extract IP and user-agent for audit logging const hdrs = await headers(); const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; const ua = hdrs.get('user-agent') ?? 'unknown'; // 3. ATOMIC ONE-TIME ENFORCEMENT // UPDATE signing_tokens SET used_at = NOW() WHERE jti = ? AND used_at IS NULL RETURNING jti // If 0 rows returned, token was already used — return 409, do NOT proceed to embed PDF const claimed = await db .update(signingTokens) .set({ usedAt: new Date() }) .where(and(eq(signingTokens.jti, payload.jti), isNull(signingTokens.usedAt))) .returning({ jti: signingTokens.jti }); if (claimed.length === 0) { return NextResponse.json({ error: 'already-signed' }, { status: 409 }); } // 4. Log signature_submitted audit event (after atomic claim, before PDF work) await logAuditEvent({ documentId: payload.documentId, eventType: 'signature_submitted', ipAddress: ip, userAgent: ua, }); // 5. Fetch document with signatureFields and preparedFilePath const doc = await db.query.documents.findFirst({ where: eq(documents.id, payload.documentId), columns: { id: true, name: true, signatureFields: true, preparedFilePath: true, clientId: true, }, }); // 6. Guard: preparedFilePath must be set (should never happen after atomics, but defensive) if (!doc || !doc.preparedFilePath) { return NextResponse.json({ error: 'document-not-found' }, { status: 422 }); } // 7. Build absolute paths // Stored paths are relative (e.g. clients/{id}/{uuid}_prepared.pdf) const preparedAbsPath = path.join(UPLOADS_DIR, doc.preparedFilePath); const signedRelPath = doc.preparedFilePath.replace(/_prepared\.pdf$/, '_signed.pdf'); const signedAbsPath = path.join(UPLOADS_DIR, signedRelPath); // Path traversal guard if (!preparedAbsPath.startsWith(UPLOADS_DIR) || !signedAbsPath.startsWith(UPLOADS_DIR)) { return NextResponse.json({ error: 'forbidden' }, { status: 403 }); } // 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 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); if (!clientSig) { throw new Error(`Missing signature for field ${field.id}`); } return { fieldId: field.id, dataURL: clientSig.dataURL, x: field.x, y: field.y, width: field.width, height: field.height, page: field.page, }; }); // 9. Embed signatures into PDF — returns SHA-256 hex hash let pdfHash: string; try { pdfHash = await embedSignatureInPdf(dateStampedPath, signedAbsPath, signaturesWithCoords); } catch (err) { console.error('[sign/POST] embedSignatureInPdf failed:', err); 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 await logAuditEvent({ documentId: payload.documentId, eventType: 'pdf_hash_computed', ipAddress: ip, userAgent: ua, metadata: { hash: pdfHash, signedFilePath: signedRelPath }, }); // 11. Update documents table: status, signedAt, signedFilePath, pdfHash await db .update(documents) .set({ status: 'Signed', signedAt: now, signedFilePath: signedRelPath, pdfHash, }) .where(eq(documents.id, payload.documentId)); // 12. Fire-and-forget: agent notification email (catch silently — do NOT fail response) // Fetch client name for the email db.query.documents .findFirst({ where: eq(documents.id, payload.documentId), with: { client: { columns: { name: true } } }, columns: { name: true }, }) .then(async (freshDoc) => { try { await sendAgentNotificationEmail({ clientName: (freshDoc as { client?: { name: string } } & typeof freshDoc)?.client?.name ?? 'Client', documentName: freshDoc?.name ?? doc.name, signedAt: now, }); } catch (emailErr) { console.error('[sign/POST] agent notification email failed (non-fatal):', emailErr); } }) .catch((err) => { console.error('[sign/POST] agent notification email fetch failed (non-fatal):', err); }); // 13. Return 200 — client redirects to /sign/[token]/confirmed return NextResponse.json({ ok: true }); }