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>
210 lines
7.9 KiB
TypeScript
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;
|