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.
289 lines
9.9 KiB
TypeScript
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;
|