feat(06-04): POST /api/sign/[token] atomic submission + confirmed page
- 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
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#FAF9F7',
|
||||
fontFamily: 'Georgia, serif',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center', maxWidth: '480px', padding: '40px' }}>
|
||||
{/* Branded header strip */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#1B2B4B',
|
||||
color: '#fff',
|
||||
padding: '16px 24px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', letterSpacing: '0.02em' }}>
|
||||
Teressa Copeland Homes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success card */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderTop: 'none',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
padding: '40px 32px',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '56px', marginBottom: '16px', color: '#22c55e' }}>✓</div>
|
||||
<h1 style={{ color: '#1B2B4B', fontSize: '24px', marginBottom: '12px', margin: '0 0 12px' }}>
|
||||
Document Signed
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: '#555',
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
margin: '0 0 24px',
|
||||
}}
|
||||
>
|
||||
Your signature has been recorded and the document has been securely submitted.
|
||||
Teressa Copeland will be in touch shortly.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#999',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
paddingTop: '16px',
|
||||
}}
|
||||
>
|
||||
You may close this window.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user