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:
parent
37a22cf474
commit
40844b2aff
6 changed files with 879 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
233
api/src/routes/portal-esign-generic.ts
Normal file
233
api/src/routes/portal-esign-generic.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue