/** * Generic eSign portal — client signs any compliance document. * * GET /api/v1/portal/esign — document info + presigned PDF URL * POST /api/v1/portal/esign — accept signature, advance pipeline * * Supported document types: rmd, cpni, calea, 499a-engagement, * discontinuance, crtc, and any future types added to esign_records. * * Flow: * 1. Service handler generates document, uploads PDF to MinIO * 2. Handler inserts row into esign_records (status = 'pending') * 3. Handler emails client a JWT link: /portal/esign?token= * 4. Client opens page → GET /portal/esign → sees PDF + signing UI * 5. Client signs → POST /portal/esign → signature stored, status = 'signed' * 6. API dispatches resume job to workers so the pipeline continues * * The portal JWT carries { order_id, order_type, email }. * order_id = order_number, order_type = document_type. */ import { Router, type Request, type Response } from "express"; import { pool } from "../db.js"; import { requirePortalAuth } from "../middleware/portalAuth.js"; import { SIGN_CONSENT_TEXT, requiresSignConsent, signConsentSatisfied, } from "./esign-sign-consent.js"; const router = Router(); const WORKER_URL = process.env.WORKER_URL || "http://workers:8090"; /** Ask the workers job server for a presigned MinIO GET URL. */ async function presignGet(key: string, expires = 3600): Promise { if (!key) return null; try { const r = await fetch(`${WORKER_URL}/jobs/presign`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, expires, method: "GET" }), }); if (!r.ok) return null; const data = (await r.json()) as { url?: string }; return data.url || null; } catch { return null; } } // ── GET /api/v1/portal/esign ──────────────────────────────────────────────── // // Returns document info for the signing page. Token identifies the order + doc type. // router.get("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res: Response) => { const { order_id: orderNumber, order_type: documentType, email } = req.portalAuth!; try { const { rows } = await pool.query( `SELECT id, order_number, document_type, document_title, entity_name, document_minio_key, document_metadata, requires_perjury, status, signed_at FROM esign_records WHERE order_number = $1 AND document_type = $2 AND status IN ('pending', 'signed') ORDER BY created_at DESC LIMIT 1`, [orderNumber, documentType], ); if (!rows.length) { res.status(404).json({ error: "No document found for this signing link. It may have expired." }); return; } const rec = rows[0] as Record; // Already signed — return confirmation if (rec.status === "signed") { res.json({ already_signed: true, signed_at: rec.signed_at, document_title: rec.document_title, entity_name: rec.entity_name, }); return; } // Get presigned URL for PDF preview let documentUrl: string | null = null; if (rec.document_minio_key) { documentUrl = await presignGet(rec.document_minio_key); } // Pull order number for subtitle (e.g. "CO-ABC12345") const metadata = rec.document_metadata || {}; // Standard-path documents require an explicit, per-document authorization to // use the signer's drawn signature to complete and submit the filing. The // signing page surfaces and collects it BEFORE capturing the signature. const requireSignConsent = requiresSignConsent(metadata); res.json({ document_title: rec.document_title, entity_name: rec.entity_name, order_number: rec.order_number, document_url: documentUrl, requires_perjury: rec.requires_perjury || false, require_sign_consent: requireSignConsent, sign_consent_text: requireSignConsent ? SIGN_CONSENT_TEXT : null, metadata, }); } catch (err) { console.error("[portal/esign GET] Error:", err); res.status(500).json({ error: "Could not load document. Please try again." }); } }); // ── POST /api/v1/portal/esign ─────────────────────────────────────────────── // // Body: { token, signature: { type, image_b64|name }, agreed_at, user_agent } // router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res: Response) => { const { order_id: orderNumber, order_type: documentType, email } = req.portalAuth!; const { signature, agreed_at, user_agent, sign_consent } = req.body as { signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string; vector?: any }; agreed_at?: string; user_agent?: string; sign_consent?: boolean; }; // Validate signature if (!signature || !signature.type) { res.status(400).json({ error: "Signature is required." }); return; } if (signature.type === "drawn") { if (!signature.image_b64 || signature.image_b64.length < 200) { res.status(400).json({ error: "Please draw your signature before submitting." }); return; } } else if (signature.type === "typed") { if (!signature.name || signature.name.trim().length < 2) { res.status(400).json({ error: "Please type your full name." }); return; } } else { res.status(400).json({ error: "Invalid signature type." }); return; } try { // Find the pending record const { rows } = await pool.query( `SELECT id, order_number, document_type, status, requires_perjury, document_metadata, signed_at FROM esign_records WHERE order_number = $1 AND document_type = $2 AND status IN ('pending', 'signed') ORDER BY created_at DESC LIMIT 1`, [orderNumber, documentType], ); if (!rows.length) { res.status(404).json({ error: "No document found for this signing link." }); return; } const rec = rows[0] as Record; // Idempotent — already signed if (rec.status === "signed") { res.json({ success: true, already_signed: true, signed_at: rec.signed_at }); return; } // Signing-authorization gate: Standard-path documents require an explicit, // per-document authorization to use the signer's drawn signature to complete // and submit the filing. A typed signature is exempt. const requireSignConsent = requiresSignConsent(rec.document_metadata); if (!signConsentSatisfied(rec.document_metadata, signature.type, sign_consent)) { res.status(400).json({ error: "Please confirm the authorization to use your signature to complete this filing.", }); return; } // Store the signature const sigData = signature.type === "drawn" ? signature.image_b64!.replace(/^data:image\/png;base64,/, "") : signature.name!.trim(); // Sanitize the optional vector (stroke paths) — bound the size so a hostile // client can't store an enormous JSON blob. Only kept for drawn signatures. let sigVector: string | null = null; if (signature.type === "drawn" && signature.vector && Array.isArray(signature.vector.strokes)) { const v = signature.vector; const strokeCount = v.strokes.length; const pointCount = v.strokes.reduce((n: number, s: any) => n + (Array.isArray(s) ? s.length : 0), 0); if (strokeCount > 0 && strokeCount <= 500 && pointCount > 0 && pointCount <= 20000) { sigVector = JSON.stringify({ v: 1, w: Number(v.w) || 0, h: Number(v.h) || 0, strokes: v.strokes, }); } } const clientIp = (req as any).clientIp || req.ip || ""; const signedAt = new Date().toISOString(); // Record the signing authorization (only meaningful when this document // requires it and the signer drew their signature). NB: the DB columns remain // named ink_consent* for migration compatibility; they store the generic // signing authorization. const signConsentGiven = requireSignConsent && signature.type === "drawn" && sign_consent === true; await pool.query( `UPDATE esign_records SET status = 'signed', signature_type = $1, signature_data = $2, signature_vector = $3, signer_email = $4, signer_ip = $5, signer_user_agent = $6, signed_at = $7, perjury_agreed = $8, ink_consent = $9, ink_consent_at = $10, ink_consent_text = $11, updated_at = NOW() WHERE id = $12`, [ signature.type, sigData, sigVector, email, clientIp, user_agent || "", signedAt, rec.requires_perjury ? true : false, signConsentGiven, signConsentGiven ? signedAt : null, signConsentGiven ? SIGN_CONSENT_TEXT : null, rec.id, ], ); // Notify workers to resume the pipeline try { await fetch(`${WORKER_URL}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "esign_completed", order_number: orderNumber, document_type: documentType, esign_record_id: rec.id, signer_email: email, }), }); } catch (dispatchErr) { // Non-fatal — worker will pick up on next scheduled run console.error("[portal/esign POST] Worker dispatch failed:", dispatchErr); } res.json({ success: true, signed_at: signedAt, message: "Your signature has been recorded. Thank you.", }); } catch (err) { console.error("[portal/esign POST] Error:", err); res.status(500).json({ error: "Could not record signature. Please try again." }); } }); export default router;