new-site/api/src/routes/portal-setup.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

251 lines
10 KiB
TypeScript

/**
* Portal Setup — client selects mailbox unit + Canadian DID after payment.
*
* GET /api/v1/portal/setup-info — order details + AMB location + flags
* GET /api/v1/portal/setup-units — scrape available unit numbers from AMB
* GET /api/v1/portal/setup-dids — search available Canadian DIDs from Flowroute
* POST /api/v1/portal/setup-confirm — client confirms → dispatches purchase job
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
import { requirePortalAuth } from "../middleware/portalAuth.js";
const router = Router();
const FLOWROUTE_KEY = process.env.FLOWROUTE_ACCESS_KEY || "";
const FLOWROUTE_SECRET = process.env.FLOWROUTE_SECRET_KEY || "";
const FLOWROUTE_BASE = "https://api.flowroute.com";
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
// ── GET /api/v1/portal/setup-info ──────────────────────────────────────────
router.get("/api/v1/portal/setup-info", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = (req as any).portalAuth?.order_id || req.query.order_id;
if (!orderId) { res.status(400).json({ error: "order_id required" }); return; }
try {
const { rows } = await pool.query(
`SELECT o.order_number, o.customer_name, o.customer_email, o.company_type,
o.director_name, o.entity_name, o.has_own_ca_address,
o.amb_location_slug, o.amb_annual_price_cents,
o.client_selected_unit, o.client_selected_did,
o.funds_available, o.payment_status,
a.name AS amb_name, a.full_address AS amb_address, a.city AS amb_city,
a.provider_url AS amb_url
FROM canada_crtc_orders o
LEFT JOIN amb_locations a ON a.slug = o.amb_location_slug
WHERE o.order_number = $1`,
[orderId],
);
if (!rows.length) { res.status(404).json({ error: "Order not found" }); return; }
const order = rows[0] as Record<string, unknown>;
res.json({
order_number: order.order_number,
customer_name: order.customer_name,
has_own_ca_address: order.has_own_ca_address,
company_type: order.company_type,
funds_available: order.funds_available,
payment_status: order.payment_status,
amb_location: order.amb_location_slug ? {
slug: order.amb_location_slug,
name: order.amb_name,
address: order.amb_address,
city: order.amb_city,
url: order.amb_url,
annual_price_cents: order.amb_annual_price_cents,
} : null,
selected_unit: order.client_selected_unit || null,
selected_did: order.client_selected_did || null,
});
} catch (err: any) {
console.error("[portal-setup] setup-info error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// ── GET /api/v1/portal/setup-units ─────────────────────────────────────────
// Dispatches a scrape job to the workers container and returns available units.
// For now, returns a placeholder — real scraping will be async via worker.
router.get("/api/v1/portal/setup-units", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = (req as any).portalAuth?.order_id || req.query.order_id;
if (!orderId) { res.status(400).json({ error: "order_id required" }); return; }
try {
// Get the AMB location URL for this order
const { rows } = await pool.query(
`SELECT a.provider_url
FROM canada_crtc_orders o
JOIN amb_locations a ON a.slug = o.amb_location_slug
WHERE o.order_number = $1 AND o.has_own_ca_address = FALSE`,
[orderId],
);
if (!rows.length) {
res.status(404).json({ error: "No mailbox location selected for this order" });
return;
}
const locationUrl = rows[0].provider_url as string;
// Call the worker to scrape available units
try {
const workerRes = await fetch(`${WORKER_URL}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "scrape_amb_units",
data: { location_url: locationUrl, order_id: orderId },
}),
});
const workerData = await workerRes.json() as { units?: string[]; error?: string };
if (workerData.units) {
res.json({ units: workerData.units, location_url: locationUrl });
} else {
res.json({ units: [], error: workerData.error || "No units found" });
}
} catch (workerErr) {
console.error("[portal-setup] Worker scrape_amb_units failed:", workerErr);
res.status(502).json({ error: "Could not fetch available units. Please try again." });
}
} catch (err: any) {
console.error("[portal-setup] setup-units error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// ── GET /api/v1/portal/setup-dids ──────────────────────────────────────────
// Returns available Canadian DIDs from Flowroute (up to 10 per area code).
// Area codes are selected based on the order's incorporation province.
router.get("/api/v1/portal/setup-dids", requirePortalAuth, async (_req: Request, res: Response) => {
if (!FLOWROUTE_KEY || !FLOWROUTE_SECRET) {
res.status(503).json({ error: "DID provider not configured" });
return;
}
const CA_AREA_CODES: Record<string, string[]> = {
BC: ["604", "778", "236", "250"],
ON: ["416", "437", "647", "905", "289", "365", "519", "226", "548", "613", "343", "705", "249", "807"],
};
// Determine province from the order (default BC for backward compat)
const province = ((_req as any).portalAuth?.province || "BC").toUpperCase();
const areaCodes = CA_AREA_CODES[province] || CA_AREA_CODES.BC;
const auth = Buffer.from(`${FLOWROUTE_KEY}:${FLOWROUTE_SECRET}`).toString("base64");
try {
const results: Record<string, Array<{ number: string; formatted: string; monthly_cost: string }>> = {};
for (const areaCode of areaCodes) {
try {
const r = await fetch(
`${FLOWROUTE_BASE}/v2/numbers/available?starts_with=1${areaCode}&limit=10&contains=&ends_with=&rate_center=&state=`,
{ headers: { Authorization: `Basic ${auth}` } },
);
if (!r.ok) continue;
const data = await r.json() as { data?: Array<{ id: string; attributes?: { value?: string; monthly_cost?: string } }> };
results[areaCode] = (data.data || []).map(d => ({
number: d.id || d.attributes?.value || "",
formatted: formatDID(d.id || d.attributes?.value || ""),
monthly_cost: d.attributes?.monthly_cost || "1.00",
}));
} catch {
results[areaCode] = [];
}
}
res.json({ area_codes: areaCodes, dids: results });
} catch (err: any) {
console.error("[portal-setup] setup-dids error:", err);
res.status(500).json({ error: "Failed to search available numbers" });
}
});
function formatDID(raw: string): string {
const digits = raw.replace(/\D/g, "");
if (digits.length === 11 && digits.startsWith("1")) {
return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
}
if (digits.length === 10) {
return `+1 (${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
return raw;
}
// ── POST /api/v1/portal/setup-confirm ──────────────────────────────────────
// Client confirms mailbox unit + DID selection → dispatch purchase job.
router.post("/api/v1/portal/setup-confirm", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = (req as any).portalAuth?.order_id || req.body?.order_id;
const { selected_unit, selected_did } = req.body || {};
if (!orderId) { res.status(400).json({ error: "order_id required" }); return; }
try {
// Validate order exists and is in Client Selection state
const { rows } = await pool.query(
`SELECT order_number, has_own_ca_address, funds_available, client_selected_unit, client_selected_did
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, unknown>;
if (!order.funds_available) {
res.status(409).json({ error: "Funds not yet available for this order" });
return;
}
// Validate: DID is always required
if (!selected_did) {
res.status(400).json({ error: "Please select a Canadian phone number" });
return;
}
// Validate: unit required unless own address
if (!order.has_own_ca_address && !selected_unit) {
res.status(400).json({ error: "Please select a mailbox unit number" });
return;
}
// Store selections
await pool.query(
`UPDATE canada_crtc_orders
SET client_selected_unit = $1,
client_selected_did = $2
WHERE order_number = $3`,
[selected_unit || null, selected_did, orderId],
);
// Dispatch purchase job to workers
try {
await fetch(`${WORKER_URL}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "purchase_client_selections",
data: {
order_id: orderId,
selected_unit: selected_unit || null,
selected_did: selected_did,
has_own_ca_address: order.has_own_ca_address,
},
}),
});
} catch (workerErr) {
console.error("[portal-setup] Worker dispatch failed:", workerErr);
// Non-blocking — the selections are saved, admin can trigger manually
}
res.json({
success: true,
message: "Selections confirmed. We're now provisioning your mailbox and phone number.",
order_number: orderId,
selected_unit: selected_unit || null,
selected_did: selected_did,
});
} catch (err: any) {
console.error("[portal-setup] setup-confirm error:", err);
res.status(500).json({ error: "Internal error" });
}
});
export default router;