Add generic eSign portal for all compliance document types

Reusable signing flow: service handler generates document → inserts
esign_records row → emails JWT link → client reviews PDF + signs →
API stores signature + resumes pipeline. Works for RMD, CPNI, CALEA,
499-A engagement, discontinuance, CRTC, and any future doc types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-05-04 10:45:37 -05:00
parent 37a22cf474
commit 40844b2aff
6 changed files with 879 additions and 0 deletions

View file

@ -43,6 +43,7 @@ import adminCryptoRouter from "./routes/admin-crypto.js";
import foreignQualRouter from "./routes/foreign-qualification.js";
import corpStatusRouter from "./routes/corp-status.js";
import portalRmdReviewRouter from "./routes/portal-rmd-review.js";
import portalEsignGenericRouter from "./routes/portal-esign-generic.js";
import pucRouter from "./routes/puc.js";
import fccCarrierRegRouter from "./routes/fcc-carrier-registration.js";
@ -101,6 +102,7 @@ app.use("/api/v1/auth", portalAuthRouter);
app.use(portalSetupRouter);
app.use(portalEsignRouter);
app.use(portalRmdReviewRouter);
app.use(portalEsignGenericRouter);
app.use("/api/v1/portal", portalRouter); // Must be AFTER specific portal routes (uses catch-all customer-auth)
app.use(fccLookupRouter);
app.use(corpStatusRouter);

View file

@ -0,0 +1,233 @@
/**
* 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";
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 || {};
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,
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 } = req.body as {
signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string };
agreed_at?: string;
user_agent?: string;
};
// 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
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;
}
// Store the signature
const sigData = signature.type === "drawn"
? signature.image_b64!.replace(/^data:image\/png;base64,/, "")
: signature.name!.trim();
const clientIp = (req as any).clientIp || req.ip || "";
const signedAt = new Date().toISOString();
await pool.query(
`UPDATE esign_records
SET status = 'signed',
signature_type = $1,
signature_data = $2,
signer_email = $3,
signer_ip = $4,
signer_user_agent = $5,
signed_at = $6,
perjury_agreed = $7,
updated_at = NOW()
WHERE id = $8`,
[
signature.type,
sigData,
email,
clientIp,
user_agent || "",
signedAt,
rec.requires_perjury ? true : false,
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;