new-site/api/src/routes/portal-esign.ts
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

210 lines
7.9 KiB
TypeScript

/**
* Portal eSign — client signs the CRTC notification letter.
*
* GET /api/v1/portal/esign-info — letter details + presigned URL for PDF preview
* POST /api/v1/portal/esign-submit — accept signature (base64 PNG), store, advance pipeline
*
* Flow:
* 1. Pipeline generates CRTC letter (Step 6), uploads to MinIO, sets workflow
* state to "Pending eSign", stores MinIO object key on the Sales Order.
* 2. Pipeline emails client a signed JWT link:
* https://performancewest.net/portal/sign?token=<jwt>
* 3. Client opens /portal/sign.astro → fetches /esign-info → shows letter preview
* 4. Client draws signature → POST /esign-submit
* 5. API stores signature PNG as base64 in PG, records signed-at timestamp,
* advances ERPNext workflow → "CRTC Submitted".
* 6. Pipeline resumes at Step 7 (binder compilation) via worker job dispatch.
*
* Storage:
* Signature PNG is stored in PG (esign_signature_b64 TEXT) — signatures are
* small (<50KB) so PG is fine. The letter PDF presigned URL is generated by
* the Python workers job server (which already has MinIO client) to avoid
* adding a MinIO npm dependency to the API.
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
import { requirePortalAuth } from "../middleware/portalAuth.js";
import { callMethod, updateResource, getResource } from "../erpnext-client.js";
const router = Router();
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
const SITE_URL = process.env.SITE_URL || "https://performancewest.net";
// ── GET /api/v1/portal/esign-info ────────────────────────────────────────────
router.get("/api/v1/portal/esign-info", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = req.portalAuth!.order_id;
try {
const { rows } = await pool.query(
`SELECT order_number, customer_name, entity_name, customer_email,
crtc_letter_minio_key, esign_signed_at,
erpnext_sales_order
FROM canada_crtc_orders
WHERE order_number = $1`,
[orderId],
);
if (!rows.length) { res.status(404).json({ error: "Order not found" }); return; }
const order = rows[0] as Record<string, any>;
// Fetch BC number + regulatory email from ERPNext
let bcNumber = "", regulatoryEmail = "", soName = order.erpnext_sales_order || "";
try {
if (soName) {
const so = await getResource("Sales Order", soName, undefined, [
"name", "custom_incorporation_number", "custom_regulatory_email",
]) as Record<string, any>;
bcNumber = so.custom_incorporation_number || "";
regulatoryEmail = so.custom_regulatory_email || "";
}
} catch { /* non-fatal */ }
// Ask the workers job server for a presigned URL for the letter PDF
let letterPreviewUrl = "";
const letterKey = order.crtc_letter_minio_key as string | null;
if (letterKey) {
try {
const resp = await fetch(`${WORKER_URL}/jobs/presign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: letterKey, expires: 3600 }),
});
if (resp.ok) {
const data = await resp.json() as { url?: string };
letterPreviewUrl = data.url || "";
}
} catch (e) {
// Non-fatal — client can still sign without preview
}
}
res.json({
order_number: order.order_number,
entity_name: order.entity_name || order.customer_name,
customer_name: order.customer_name,
bc_number: bcNumber,
regulatory_email: regulatoryEmail,
letter_preview_url: letterPreviewUrl,
already_signed: !!order.esign_signed_at,
signed_at: order.esign_signed_at || null,
});
} catch (err: any) {
console.error("[esign-info] error:", err);
res.status(500).json({ error: "Failed to load signing info" });
}
});
// ── POST /api/v1/portal/esign-submit ─────────────────────────────────────────
//
// Body: { signature_png: "<data:image/png;base64,...>", agreed: true }
//
router.post("/api/v1/portal/esign-submit", requirePortalAuth, async (req: Request, res: Response) => {
const { order_id: orderId, email } = req.portalAuth!;
const { signature_png, agreed } = req.body as {
signature_png?: string;
agreed?: boolean;
};
if (!agreed) {
res.status(400).json({ error: "You must confirm that you have read and agree to sign the letter." });
return;
}
if (!signature_png || signature_png.length < 100) {
res.status(400).json({ error: "A valid signature is required. Please draw your signature in the box." });
return;
}
try {
const { rows } = await pool.query(
`SELECT order_number, entity_name, customer_name, customer_email,
esign_signed_at, erpnext_sales_order
FROM canada_crtc_orders WHERE order_number = $1`,
[orderId],
);
if (!rows.length) { res.status(404).json({ error: "Order not found." }); return; }
const order = rows[0] as Record<string, any>;
// Idempotent — already signed
if (order.esign_signed_at) {
res.json({ success: true, already_signed: true, signed_at: order.esign_signed_at });
return;
}
// Verify email matches
if ((order.customer_email as string)?.toLowerCase() !== email.toLowerCase()) {
res.status(403).json({ error: "This link is not valid for your account." });
return;
}
// Validate signature is a real PNG (not empty canvas)
const pngData = signature_png.replace(/^data:image\/png;base64,/, "");
const pngBuffer = Buffer.from(pngData, "base64");
if (pngBuffer.length < 200) {
res.status(400).json({ error: "Signature appears to be empty. Please draw your signature." });
return;
}
const signedAt = new Date().toISOString();
const soName = order.erpnext_sales_order as string | null;
// Store in PG — signature as base64 string, signed timestamp
await pool.query(
`UPDATE canada_crtc_orders
SET esign_signed_at = $1, esign_signature_b64 = $2, esign_signer_email = $3
WHERE order_number = $4`,
[signedAt, pngData, email, orderId],
);
// Update ERPNext Sales Order
if (soName) {
try {
await updateResource("Sales Order", soName, {
custom_esign_signed_at: signedAt,
custom_esign_signer_email: email,
});
} catch (e) { /* non-fatal */ }
// Advance workflow → CRTC Submitted
try {
await callMethod("frappe.model.workflow.apply_workflow", {
doc: { doctype: "Sales Order", name: soName },
action: "Submit CRTC Letter",
});
} catch {
// Fallback: direct state set
try {
await callMethod("frappe.client.set_value", {
doctype: "Sales Order",
name: soName,
field: "workflow_state",
value: "CRTC Submitted",
});
} catch (e) { /* non-fatal */ }
}
}
// Dispatch pipeline resume via workers job server
try {
await fetch(`${WORKER_URL}/jobs/resume_crtc_pipeline`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId, step: "post_esign" }),
});
} catch (e) { /* non-fatal — pipeline will catch up on next scheduled run */ }
res.json({
success: true,
signed_at: signedAt,
message: "Signature received. Your CRTC registration letter is being submitted.",
});
} catch (err: any) {
console.error("[esign-submit] error:", err);
res.status(500).json({ error: "Failed to record signature. Please try again." });
}
});
export default router;