feat(15-03): signer-aware POST handler with accumulate PDF and atomic completion

- Step 3.5: fetch signerEmail from tokenRow after atomic claim
- Step 7: accumulate pattern — read from signedFilePath or preparedFilePath as working PDF
- Step 7: write to JTI-keyed _partial_ path to prevent concurrent signer collisions
- Step 8a: date stamps scoped to this signer's date fields (D-09)
- Step 8b: signable fields scoped to this signer's fields (Pitfall 4)
- Step 9.5: JTI-keyed datestamped temp file to prevent collision
- Step 10.5: update signedFilePath to this signer's partial after each signing
- Step 11: remaining-token count check before completion attempt
- Step 11: completionTriggeredAt atomic guard (UPDATE WHERE IS NULL RETURNING)
- Step 12: status='Signed' only in completion winner block (fixes first-signer-wins bug)
- Step 13: agent notification + signer completion emails only at completion
- Legacy null-signerEmail tokens fall through all signer filters unchanged
This commit is contained in:
Chandler Copeland
2026-04-03 15:47:37 -06:00
parent 7a04a4f617
commit 1749e10e9c

View File

@@ -142,12 +142,19 @@ export async function POST(
return NextResponse.json({ error: 'already-signed' }, { status: 409 });
}
// 3.5. Fetch tokenRow to get signerEmail (already claimed above — safe to read)
const tokenRow = await db.query.signingTokens.findFirst({
where: eq(signingTokens.jti, payload.jti),
});
const signerEmail = tokenRow?.signerEmail ?? null;
// 4. Log signature_submitted audit event (after atomic claim, before PDF work)
await logAuditEvent({
documentId: payload.documentId,
eventType: 'signature_submitted',
ipAddress: ip,
userAgent: ua,
...(signerEmail ? { metadata: { signerEmail } } : {}),
});
// 5. Fetch document with signatureFields and preparedFilePath
@@ -167,29 +174,39 @@ export async function POST(
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);
// 7. Build paths — accumulate pattern (D-10)
// Re-fetch to get latest signedFilePath (another signer may have already updated it)
const freshDoc = await db.query.documents.findFirst({
where: eq(documents.id, payload.documentId),
columns: { signedFilePath: true, preparedFilePath: true },
});
const workingRelPath = freshDoc?.signedFilePath ?? freshDoc?.preparedFilePath ?? doc.preparedFilePath!;
const workingAbsPath = path.join(UPLOADS_DIR, workingRelPath);
// Path traversal guard
if (!preparedAbsPath.startsWith(UPLOADS_DIR) || !signedAbsPath.startsWith(UPLOADS_DIR)) {
// Per-signer partial output path (unique by JTI — no collisions between concurrent signers)
const partialRelPath = doc.preparedFilePath!.replace(/_prepared\.pdf$/, `_partial_${payload.jti}.pdf`);
const partialAbsPath = path.join(UPLOADS_DIR, partialRelPath);
// Path traversal guard (both paths)
if (!workingAbsPath.startsWith(UPLOADS_DIR) || !partialAbsPath.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'
);
// 8a. Stamp date text at each 'date' field — scoped to this signer's fields (D-09)
const dateFields = (doc.signatureFields ?? []).filter((f) => {
if (getFieldType(f) !== 'date') return false;
if (!isClientVisibleField(f)) return false;
if (signerEmail !== null) return f.signerEmail === signerEmail;
return true; // legacy: stamp all date fields
});
// Only load and modify PDF if there are date fields to stamp
let dateStampedPath = preparedAbsPath;
let dateStampedPath = workingAbsPath;
if (dateFields.length > 0) {
const pdfBytes = await readFile(preparedAbsPath);
const pdfBytes = await readFile(workingAbsPath);
const pdfDoc = await PDFDocument.load(pdfBytes);
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
@@ -207,17 +224,19 @@ export async function POST(
});
}
const stampedBytes = await pdfDoc.save();
// Write to a temporary date-stamped path; embedSignatureInPdf reads from this path
dateStampedPath = `${preparedAbsPath}.datestamped.tmp`;
// JTI-keyed temp name prevents collision between concurrent signers
dateStampedPath = `${workingAbsPath}.datestamped_${payload.jti}.tmp`;
await writeFile(dateStampedPath, stampedBytes);
}
// 8b. Build signaturesWithCoords for client-signable fields only (client-signature + initials)
// 8b. Build signaturesWithCoords — scoped to this signer's signable fields (Pitfall 4)
// 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';
if (t !== 'client-signature' && t !== 'initials') return false;
if (signerEmail !== null) return f.signerEmail === signerEmail;
return isClientVisibleField(f); // legacy
});
const signaturesWithCoords = signableFields.map((field) => {
@@ -236,17 +255,17 @@ export async function POST(
};
});
// 9. Embed signatures into PDF — returns SHA-256 hex hash
// 9. Embed signatures into PDF — input: date-stamped working PDF, output: this signer's partial
let pdfHash: string;
try {
pdfHash = await embedSignatureInPdf(dateStampedPath, signedAbsPath, signaturesWithCoords);
pdfHash = await embedSignatureInPdf(dateStampedPath, partialAbsPath, 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) {
// 9.5. Clean up temporary date-stamped file if it was created
if (dateStampedPath !== workingAbsPath) {
unlink(dateStampedPath).catch(() => {});
}
@@ -256,33 +275,75 @@ export async function POST(
eventType: 'pdf_hash_computed',
ipAddress: ip,
userAgent: ua,
metadata: { hash: pdfHash, signedFilePath: signedRelPath },
metadata: { hash: pdfHash, signedFilePath: partialRelPath, ...(signerEmail ? { signerEmail } : {}) },
});
// 11. Update documents table: status, signedAt, signedFilePath, pdfHash
// 10.5. Update signedFilePath to this signer's partial (becomes working PDF for next signer)
await db.update(documents)
.set({ signedFilePath: partialRelPath })
.where(eq(documents.id, payload.documentId));
// 11. Completion detection (D-07, D-08)
// Count remaining unclaimed tokens for this document
const remaining = await db
.select({ cnt: sql<number>`cast(count(*) as int)` })
.from(signingTokens)
.where(and(
eq(signingTokens.documentId, payload.documentId),
isNull(signingTokens.usedAt)
));
const allDone = remaining[0].cnt === 0;
if (!allDone) {
// Not all signers done — return success for this signer without completing document
return NextResponse.json({ ok: true });
}
// All tokens claimed — race for completionTriggeredAt (atomic guard)
const won = await db
.update(documents)
.set({ completionTriggeredAt: now })
.where(and(
eq(documents.id, payload.documentId),
isNull(documents.completionTriggeredAt)
))
.returning({ id: documents.id });
if (won.length === 0) {
// Another handler won the race — still success for this signer
return NextResponse.json({ ok: true });
}
// 12. Winner: finalize document — set status Signed, signedAt, final pdfHash
await db
.update(documents)
.set({
status: 'Signed',
signedAt: now,
signedFilePath: signedRelPath,
signedFilePath: partialRelPath, // already set in 10.5, confirm final value
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
// 13. Fire-and-forget: agent notification + signer completion emails (completion winner only)
const baseUrl = process.env.APP_BASE_URL ?? 'http://localhost:3000';
// Agent notification email
db.query.documents
.findFirst({
where: eq(documents.id, payload.documentId),
with: { client: { columns: { name: true } } },
columns: { name: true },
columns: { name: true, signers: true },
})
.then(async (freshDoc) => {
.then(async (notifDoc) => {
try {
const signers = (notifDoc?.signers as DocumentSigner[] | null) ?? [];
const clientName = signers.length > 0
? `${signers.length} signers`
: ((notifDoc as { client?: { name: string } } & typeof notifDoc)?.client?.name ?? 'Client');
await sendAgentNotificationEmail({
clientName: (freshDoc as { client?: { name: string } } & typeof freshDoc)?.client?.name ?? 'Client',
documentName: freshDoc?.name ?? doc.name,
clientName,
documentName: notifDoc?.name ?? doc.name,
signedAt: now,
});
} catch (emailErr) {
@@ -290,9 +351,42 @@ export async function POST(
}
})
.catch((err) => {
console.error('[sign/POST] agent notification email fetch failed (non-fatal):', err);
console.error('[sign/POST] agent notification fetch failed (non-fatal):', err);
});
// 13. Return 200 — client redirects to /sign/[token]/confirmed
// Signer completion emails — send to all signers with download link (MSIGN-11)
db.query.documents
.findFirst({
where: eq(documents.id, payload.documentId),
columns: { name: true, signers: true },
})
.then(async (completionDoc) => {
try {
const signers = (completionDoc?.signers as DocumentSigner[] | null) ?? [];
if (signers.length === 0) return; // legacy single-signer — no signer completion emails
const downloadToken = await createSignerDownloadToken(payload.documentId);
const downloadUrl = `${baseUrl}/api/sign/download/${downloadToken}`;
await Promise.all(
signers.map((signer) =>
sendSignerCompletionEmail({
to: signer.email,
documentName: completionDoc?.name ?? doc.name,
downloadUrl,
}).catch((err) => {
console.error(`[sign/POST] signer completion email to ${signer.email} failed (non-fatal):`, err);
})
)
);
} catch (err) {
console.error('[sign/POST] signer completion emails failed (non-fatal):', err);
}
})
.catch((err) => {
console.error('[sign/POST] signer completion fetch failed (non-fatal):', err);
});
// 14. Return 200 — client redirects to /sign/[token]/confirmed
return NextResponse.json({ ok: true });
}