diff --git a/teressa-copeland-homes/src/app/api/sign/[token]/route.ts b/teressa-copeland-homes/src/app/api/sign/[token]/route.ts index ad81d32..3e17318 100644 --- a/teressa-copeland-homes/src/app/api/sign/[token]/route.ts +++ b/teressa-copeland-homes/src/app/api/sign/[token]/route.ts @@ -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`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 }); }