From d445c282c19fcbb1da728434162d408ba1e66f83 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 20 Mar 2026 11:37:00 -0600 Subject: [PATCH] feat(06-04): POST /api/sign/[token] atomic submission + confirmed page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add POST handler to sign/[token]/route.ts with atomic one-time enforcement - UPDATE signing_tokens SET usedAt WHERE usedAt IS NULL RETURNING — 0 rows = 409 - Log signature_submitted and pdf_hash_computed audit events - Merge client dataURLs with server-stored field coordinates (NEVER trust client coords) - Call embedSignatureInPdf, store pdfHash + signedFilePath in documents table - Update document status to Signed with signedAt timestamp - Fire-and-forget sendAgentNotificationEmail (catches errors without failing response) - Create /sign/[token]/confirmed success page for POST redirect destination --- .../src/app/api/sign/[token]/route.ts | 157 +++++++++++++++++- .../src/app/sign/[token]/confirmed/page.tsx | 72 ++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx diff --git a/teressa-copeland-homes/src/app/api/sign/[token]/route.ts b/teressa-copeland-homes/src/app/api/sign/[token]/route.ts index 17c220e..1560e00 100644 --- a/teressa-copeland-homes/src/app/api/sign/[token]/route.ts +++ b/teressa-copeland-homes/src/app/api/sign/[token]/route.ts @@ -4,7 +4,12 @@ import { verifySigningToken } from '@/lib/signing/token'; import { logAuditEvent } from '@/lib/signing/audit'; import { db } from '@/lib/db'; import { signingTokens, documents } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, isNull, and } from 'drizzle-orm'; +import path from 'node:path'; +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( @@ -94,3 +99,153 @@ export async function GET( 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 }); + } + + // 8. Merge client-supplied dataURLs with server-stored field coordinates + // CRITICAL: x/y/width/height come ONLY from the DB — never from the request body + const signaturesWithCoords = (doc.signatureFields ?? []).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(preparedAbsPath, signedAbsPath, signaturesWithCoords); + } catch (err) { + console.error('[sign/POST] embedSignatureInPdf failed:', err); + return NextResponse.json({ error: 'pdf-embed-failed' }, { status: 500 }); + } + + // 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 + const now = new Date(); + 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), + columns: { name: true }, + }) + .then(async (freshDoc) => { + try { + await sendAgentNotificationEmail({ + clientName: freshDoc?.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 }); +} diff --git a/teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx b/teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx new file mode 100644 index 0000000..b7bd54a --- /dev/null +++ b/teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx @@ -0,0 +1,72 @@ +// /sign/[token]/confirmed — shown after a document is successfully signed +// Static success screen; no token re-validation needed (submission already committed) + +export default function ConfirmedPage() { + return ( +
+
+ {/* Branded header strip */} +
+
+ Teressa Copeland Homes +
+
+ + {/* Success card */} +
+
+

+ Document Signed +

+

+ Your signature has been recorded and the document has been securely submitted. + Teressa Copeland will be in touch shortly. +

+
+ You may close this window. +
+
+
+
+ ); +}