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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user