Files
red/teressa-copeland-homes/src/app/api/sign/[token]/route.ts

296 lines
10 KiB
TypeScript
Raw Normal View History

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 });
}