new-site/api/src/routes/portal-esign-generic.ts
justin e5db147319 esign: make signing copy fully generic - remove all ink references from website/API
Client-facing and website code now describes only a generic per-document signing
authorization; nothing visible to signers or recorded in the website/API code or
DB schema references ink, paper, reproduction, or any fulfillment mechanics.

- rename esign-ink-consent.ts -> esign-sign-consent.ts; INK_CONSENT_TEXT ->
  SIGN_CONSENT_TEXT (generic: 'use my signature to complete and submit this
  single filing', no ink/paper/reproduce language); helpers ink* -> sign*
- portal-esign-generic.ts: API field ink_reproduction -> require_sign_consent,
  ink_consent_text -> sign_consent_text, request field ink_consent -> sign_consent
- signing page (site/public/portal/esign): all ids/vars/comments ink* -> sign*;
  no 'ink' string remains
- npi_provider metadata flag ink_reproduction -> require_sign_consent
- migration 090/092 + live DB column comments rewritten to drop ink/plotter
  wording (DB column names kept as ink_consent* for compat, internal only)
- order-timeline.ts buffer comments neutralized
- tests: 37 checks, consent text asserted to omit ink/plotter/paper/reproduce/etc

DB columns ink_consent* retained (internal, never sent to clients) to avoid a
risky rename of already-applied prod columns.
2026-06-07 05:06:26 -05:00

289 lines
9.9 KiB
TypeScript

/**
* 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=<jwt>
* 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<string | null> {
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<string, any>;
// 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<string, any>;
// 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;