2026-03-20 11:28:51 -06:00
|
|
|
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';
|
2026-03-21 09:49:06 -06:00
|
|
|
import { signingTokens, documents, clients } from '@/lib/db/schema';
|
2026-03-20 11:37:00 -06:00
|
|
|
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');
|
2026-03-20 11:28:51 -06:00
|
|
|
|
|
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 10:01:46 -06:00
|
|
|
// 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)),
|
|
|
|
|
]);
|
2026-03-20 11:28:51 -06:00
|
|
|
|
|
|
|
|
// 7. Return pending state with document data
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
status: 'pending',
|
|
|
|
|
document: {
|
|
|
|
|
id: doc.id,
|
|
|
|
|
name: doc.name,
|
|
|
|
|
signatureFields: doc.signatureFields ?? [],
|
|
|
|
|
preparedFilePath: doc.preparedFilePath,
|
|
|
|
|
},
|
|
|
|
|
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-20 11:37:00 -06:00
|
|
|
|
|
|
|
|
// 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),
|
2026-03-21 09:49:06 -06:00
|
|
|
with: { client: { columns: { name: true } } },
|
2026-03-20 11:37:00 -06:00
|
|
|
columns: { name: true },
|
|
|
|
|
})
|
|
|
|
|
.then(async (freshDoc) => {
|
|
|
|
|
try {
|
|
|
|
|
await sendAgentNotificationEmail({
|
2026-03-21 09:49:06 -06:00
|
|
|
clientName: (freshDoc as { client?: { name: string } } & typeof freshDoc)?.client?.name ?? 'Client',
|
2026-03-20 11:37:00 -06:00
|
|
|
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 });
|
|
|
|
|
}
|