FCC/Akamai is blocking our server IP (403). Name search now falls back to querying the local fcc_499_filers table (20K+ records) when the live FCC search fails. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1493 lines
63 KiB
TypeScript
1493 lines
63 KiB
TypeScript
import { Router } from "express";
|
||
import { pool } from "../db.js";
|
||
|
||
const router = Router();
|
||
|
||
interface CoresData {
|
||
frn: string;
|
||
entity_name: string | null;
|
||
address: string | null;
|
||
city: string | null;
|
||
state: string | null;
|
||
zip: string | null;
|
||
status: string | null;
|
||
red_light: boolean | null;
|
||
error: string | null;
|
||
}
|
||
|
||
interface RmdData {
|
||
found: boolean;
|
||
business_name: string | null;
|
||
frn: string | null;
|
||
rmd_number: string | null;
|
||
certification_date: string | null;
|
||
implementation_type: string | null;
|
||
contact_name: string | null;
|
||
contact_email: string | null;
|
||
removed: boolean;
|
||
removal_reason: string | null;
|
||
error: string | null;
|
||
}
|
||
|
||
interface ComplianceCheck {
|
||
id: string;
|
||
label: string;
|
||
status: "green" | "yellow" | "red" | "unknown";
|
||
detail: string;
|
||
action_url: string | null;
|
||
due_date: string | null;
|
||
}
|
||
|
||
/**
|
||
* FCC Compliance Lookup
|
||
*
|
||
* GET /api/v1/fcc/lookup?frn=0012345678
|
||
*
|
||
* Queries multiple public FCC data sources + our local DB to produce
|
||
* a unified compliance status report for a given FRN.
|
||
*/
|
||
router.get("/api/v1/fcc/lookup", async (req, res) => {
|
||
const frn = (req.query.frn as string || "").replace(/\D/g, "").padStart(10, "0");
|
||
const quickMode = req.query.quick === "1"; // Skip slow checks (corp status, RMD audit)
|
||
|
||
if (!frn || frn.length !== 10 || frn === "0000000000") {
|
||
res.status(400).json({ error: "Please provide a valid 10-digit FRN." });
|
||
return;
|
||
}
|
||
|
||
const startMs = Date.now();
|
||
try {
|
||
// Phase 1: Fast local DB lookups first (~10ms)
|
||
const [localRmdP, localRemovedP, local499P] = await Promise.allSettled([
|
||
fetchLocalRmd(frn),
|
||
fetchLocalRemoved(frn),
|
||
fetchLocal499Filer(frn),
|
||
]);
|
||
|
||
const localRmdResult = localRmdP.status === "fulfilled" ? localRmdP.value : null;
|
||
const local499Result = local499P.status === "fulfilled" ? local499P.value : null;
|
||
const filerId = local499Result?.filer_id || null;
|
||
|
||
// Phase 2: External API calls — only fetch what we DON'T have locally.
|
||
// For carriers in our DB (most of them), skip the slow CORES + RMD scrapes entirely.
|
||
const hasLocalData = !!(localRmdResult || local499Result);
|
||
const phase2: Record<string, Promise<unknown>> = {};
|
||
if (!hasLocalData) {
|
||
// Unknown carrier — need live CORES + RMD lookups
|
||
phase2.cores = fetchCoresData(frn);
|
||
phase2.rmd = fetchRmdData(frn);
|
||
} else {
|
||
// Known carrier — use local data, skip slow external scrapes
|
||
phase2.cores = Promise.resolve(null);
|
||
phase2.rmd = Promise.resolve(null);
|
||
}
|
||
if (filerId) {
|
||
phase2.filerDetail = fetch499Detail(filerId);
|
||
phase2.cpniResult = fetchCpniStatus(filerId);
|
||
}
|
||
// Corp status
|
||
const corpEntityName = localRmdResult?.business_name || local499Result?.legal_name;
|
||
const corpPhysicalState = local499Result?.state;
|
||
if (!quickMode && corpEntityName && corpPhysicalState && !(localRmdResult?.foreign_voice_provider === true)) {
|
||
phase2.corpStatus = (async () => {
|
||
try {
|
||
const { lookupCorpStatus } = await import("./corp-status.js");
|
||
// Check physical state for foreign entity redirect
|
||
const localMatch = await pool.query(
|
||
`SELECT entity_type, formation_state FROM entity_cache
|
||
WHERE state = $1 AND LOWER(entity_name) = LOWER($2) LIMIT 1`,
|
||
[corpPhysicalState, corpEntityName],
|
||
).catch(() => ({ rows: [] }));
|
||
if (localMatch.rows.length > 0) {
|
||
const m = localMatch.rows[0] as Record<string, unknown>;
|
||
const eType = ((m.entity_type as string) || "").toUpperCase();
|
||
const formSt = (m.formation_state as string) || "";
|
||
if (eType.includes("FOREIGN") && formSt && formSt !== corpPhysicalState) {
|
||
return lookupCorpStatus(corpEntityName, formSt);
|
||
}
|
||
}
|
||
return lookupCorpStatus(corpEntityName, corpPhysicalState!);
|
||
} catch { return null; }
|
||
})();
|
||
}
|
||
|
||
// Wait for all phase 2 in parallel
|
||
const phase2Keys = Object.keys(phase2);
|
||
const phase2Results = await Promise.allSettled(Object.values(phase2));
|
||
const p2: Record<string, unknown> = {};
|
||
phase2Keys.forEach((k, i) => {
|
||
p2[k] = phase2Results[i].status === "fulfilled" ? (phase2Results[i] as PromiseFulfilledResult<unknown>).value : null;
|
||
});
|
||
|
||
let filerDetail = (p2.filerDetail as { current_as_of: string | null; comm_type: string | null; contributor: boolean | null; hq_address: string | null; hq_city: string | null; hq_state: string | null; hq_zip: string | null; error: string | null }) || null;
|
||
let cpniResult = (p2.cpniResult as { filed: boolean; cert_year: number | null; date_filed: string | null; error: string | null }) || null;
|
||
const coresResult = (p2.cores as CoresData) || { frn, entity_name: null, address: null, city: null, state: null, zip: null, status: null, red_light: null, error: "CORES lookup failed" } as CoresData;
|
||
const rmdResult = (p2.rmd as RmdData) || { found: false, business_name: null, frn: null, rmd_number: null, certification_date: null, implementation_type: null, contact_name: null, contact_email: null, removed: false, removal_reason: null, error: "RMD lookup failed" } as RmdData;
|
||
const localRemovedResult = localRemovedP.status === "fulfilled" ? localRemovedP.value : null;
|
||
|
||
// Prefer local 499 filer data over CORES scrape for entity info
|
||
if (!coresResult.entity_name && local499Result) {
|
||
coresResult.entity_name = local499Result.legal_name;
|
||
coresResult.state = local499Result.state;
|
||
coresResult.status = "found_local";
|
||
coresResult.error = null;
|
||
}
|
||
// Merge RMD data: prefer live API, enrich from local DB
|
||
if (localRmdResult) {
|
||
if (!rmdResult.found) {
|
||
rmdResult.found = true;
|
||
rmdResult.business_name = localRmdResult.business_name;
|
||
rmdResult.rmd_number = localRmdResult.rmd_number;
|
||
rmdResult.contact_name = localRmdResult.contact_name || localRmdResult.robocall_contact_name;
|
||
rmdResult.contact_email = localRmdResult.contact_email || localRmdResult.robocall_contact_email;
|
||
rmdResult.implementation_type = localRmdResult.implementation;
|
||
}
|
||
|
||
// Use best available date: last_recertified > last_updated > csv_imported_at
|
||
// Normalize all dates to YYYY-MM-DD strings
|
||
if (!rmdResult.certification_date) {
|
||
const rawDate = localRmdResult.last_recertified || localRmdResult.last_updated || localRmdResult.csv_imported_at;
|
||
if (rawDate) {
|
||
const d = rawDate instanceof Date ? rawDate : new Date(rawDate);
|
||
rmdResult.certification_date = d.toISOString().slice(0, 10);
|
||
}
|
||
}
|
||
|
||
// Check if the provider has been removed from RMD
|
||
if (localRmdResult.removed_from_rmd) {
|
||
rmdResult.removed = true;
|
||
rmdResult.removal_reason = `Removed from RMD registry on ${localRmdResult.removed_at ? new Date(localRmdResult.removed_at).toISOString().slice(0, 10) : "unknown date"}. Provider was previously registered but no longer appears in the FCC Robocall Mitigation Database.`;
|
||
}
|
||
}
|
||
|
||
// Build compliance checks
|
||
const checks: ComplianceCheck[] = [];
|
||
|
||
// 1. FCC CORES Registration — prefer local data (RMD/499 filer), fall back to live scrape
|
||
const localEntityName = localRmdResult?.business_name || local499Result?.legal_name;
|
||
const coresName = coresResult.entity_name || localEntityName;
|
||
const coresSource = coresResult.entity_name ? "CORES" : (localRmdResult?.business_name ? "RMD registry" : (local499Result?.legal_name ? "499 filer database" : null));
|
||
|
||
// Red light: check local DB first (populated by CORES Playwright scraper), then CORES scrape
|
||
const localRedLight = localRmdResult?.red_light_status;
|
||
const isRedLight = localRedLight === "red" || coresResult.red_light === true;
|
||
const isGreenLight = localRedLight === "green" || coresResult.red_light === false;
|
||
const redLightKnown = localRedLight != null || coresResult.red_light != null;
|
||
|
||
let coresStatus: "green" | "yellow" | "red" | "unknown" = "unknown";
|
||
let coresDetail = "";
|
||
|
||
if (coresName) {
|
||
if (isRedLight) {
|
||
coresStatus = "red";
|
||
coresDetail = `${coresName} — RED LIGHT (outstanding delinquent debts)`;
|
||
} else if (isGreenLight) {
|
||
coresStatus = "green";
|
||
coresDetail = `${coresName} — GREEN (no delinquent debts)`;
|
||
} else {
|
||
coresStatus = "green";
|
||
coresDetail = `${coresName} — Active`;
|
||
}
|
||
if (coresSource && coresSource !== "CORES") coresDetail += ` (via ${coresSource})`;
|
||
} else {
|
||
coresDetail = "FRN not found in local registry. Verify directly at FCC CORES.";
|
||
}
|
||
|
||
checks.push({
|
||
id: "cores_registration",
|
||
label: "FCC CORES Registration",
|
||
status: coresStatus,
|
||
detail: coresDetail,
|
||
action_url: null,
|
||
due_date: null,
|
||
});
|
||
|
||
// 2. RMD Filing
|
||
const rmdRemoved = localRemovedResult !== null;
|
||
let rmdStatus: "green" | "yellow" | "red" | "unknown" = "unknown";
|
||
let rmdDetail = "";
|
||
|
||
if (rmdRemoved) {
|
||
rmdStatus = "red";
|
||
rmdDetail = `REMOVED from RMD — ${localRemovedResult.removal_reason || "See FCC order"}. Immediate action required.`;
|
||
} else if (rmdResult.removed) {
|
||
rmdStatus = "red";
|
||
rmdDetail = rmdResult.removal_reason || "Removed from Robocall Mitigation Database. Immediate action required.";
|
||
} else if (rmdResult.found) {
|
||
const implLabel = rmdResult.implementation_type || "compliant";
|
||
|
||
if (rmdResult.certification_date) {
|
||
const certDate = new Date(rmdResult.certification_date);
|
||
const monthsSince = (Date.now() - certDate.getTime()) / (1000 * 60 * 60 * 24 * 30);
|
||
if (monthsSince > 13) {
|
||
rmdStatus = "red";
|
||
rmdDetail = `Certification expired — last updated ${rmdResult.certification_date}. Annual recertification required by March 1.`;
|
||
} else if (monthsSince > 10) {
|
||
rmdStatus = "yellow";
|
||
rmdDetail = `Certification expiring soon — last updated ${rmdResult.certification_date}. Recertification due March 1.`;
|
||
} else {
|
||
rmdStatus = "green";
|
||
rmdDetail = `${implLabel} — last updated ${rmdResult.certification_date}`;
|
||
}
|
||
} else {
|
||
// Present in RMD CSV but no date available — still means they're registered
|
||
rmdStatus = "green";
|
||
rmdDetail = `${implLabel} — registered in RMD`;
|
||
if (rmdResult.rmd_number) rmdDetail += ` (${rmdResult.rmd_number})`;
|
||
}
|
||
} else {
|
||
rmdStatus = "red";
|
||
rmdDetail = "Not found in Robocall Mitigation Database. Registration is required for all voice service providers. If you are a data-only provider, RMD does not apply.";
|
||
}
|
||
|
||
checks.push({
|
||
id: "rmd_filing",
|
||
label: "Robocall Mitigation Database (RMD)",
|
||
status: rmdStatus,
|
||
detail: rmdDetail,
|
||
action_url: null,
|
||
due_date: "March 1 (annual recertification)",
|
||
});
|
||
|
||
// 2b. RMD Filing Quality — real-time structured analysis of the filing
|
||
// Checks for 2026 compliance: STIR/SHAKEN consistency, provider classification,
|
||
// missing contact info, and logical combinations.
|
||
if (rmdResult.found && !rmdRemoved && !quickMode && localRmdResult) {
|
||
try {
|
||
const rmdIssues: Array<{ label: string; severity: string }> = [];
|
||
|
||
// Check: Provider classification — at least one must be selected
|
||
const isVSP = localRmdResult.voice_service_provider === true;
|
||
const isGW = localRmdResult.gateway_provider === true;
|
||
const isInter = localRmdResult.intermediate_provider === true;
|
||
if (!isVSP && !isGW && !isInter) {
|
||
rmdIssues.push({ label: "No provider classification selected", severity: "critical" });
|
||
}
|
||
|
||
// Check: STIR/SHAKEN consistency
|
||
const impl = (localRmdResult.implementation || "").toLowerCase();
|
||
if (isVSP && !isGW && !isInter) {
|
||
if (impl.includes("robocall mitigation") && !impl.includes("partial") && !impl.includes("complete")) {
|
||
rmdIssues.push({ label: "VSP without STIR/SHAKEN (robocall mitigation only)", severity: "major" });
|
||
}
|
||
}
|
||
if (isInter && !isVSP && !isGW) {
|
||
if (impl.includes("complete") && !impl.includes("partial")) {
|
||
rmdIssues.push({ label: "Intermediate provider claims Complete STIR/SHAKEN", severity: "major" });
|
||
}
|
||
}
|
||
|
||
// Note: contact_email/name are often NULL in our local DB because the RMD CSV
|
||
// doesn't include them (requires separate scrape). Not a filing deficiency.
|
||
|
||
// Check: 2026 requirements — if we have the PDF audit cached, include those
|
||
try {
|
||
const auditRow = await pool.query(
|
||
`SELECT total_deficiencies, severity, structured_checks, pdf_checks
|
||
FROM fcc_rmd_audit_results
|
||
WHERE frn = $1 AND audited_at > NOW() - INTERVAL '30 days'
|
||
LIMIT 1`,
|
||
[frn],
|
||
);
|
||
if (auditRow.rows.length > 0) {
|
||
const audit = auditRow.rows[0] as Record<string, unknown>;
|
||
// Merge both structured and PDF audit checks
|
||
const pChecks = (audit.pdf_checks as Array<{ id?: string; label: string; severity: string }>) || [];
|
||
const sChecks = (audit.structured_checks as Array<{ id?: string; label: string; severity: string }>) || [];
|
||
for (const pc of [...sChecks, ...pChecks]) {
|
||
if (pc.severity === "minor") continue; // only show major/critical
|
||
if (pc.label && !rmdIssues.some(i => i.label === pc.label)) {
|
||
rmdIssues.push(pc);
|
||
}
|
||
}
|
||
}
|
||
} catch { /* audit table may not exist */ }
|
||
|
||
if (rmdIssues.length > 0) {
|
||
const worstSev = rmdIssues.some(i => i.severity === "critical") ? "critical"
|
||
: rmdIssues.some(i => i.severity === "major") ? "major" : "minor";
|
||
const labels = rmdIssues.map(i => i.label).slice(0, 5);
|
||
checks.push({
|
||
id: "rmd_quality",
|
||
label: "RMD Filing Quality (2026)",
|
||
status: worstSev === "critical" ? "red" : "yellow",
|
||
detail: `${rmdIssues.length} issue(s) found: ${labels.join("; ")}${labels.length < rmdIssues.length ? ` (+${rmdIssues.length - labels.length} more)` : ""}. Your filing does not meet all 2026 FCC requirements. We recommend refiling your RMD certification with the missing sections included.`,
|
||
action_url: null,
|
||
due_date: null,
|
||
});
|
||
}
|
||
} catch {
|
||
// Silently skip on error
|
||
}
|
||
}
|
||
|
||
// 3. STIR/SHAKEN
|
||
//
|
||
// Data source: FCC Robocall Mitigation Database (RMD) — this is the
|
||
// carrier's SELF-REPORTED implementation status. It is NOT verified
|
||
// against the STI-PA (iConectiv) certificate authority database.
|
||
// A carrier can claim "Complete STIR/SHAKEN" in RMD without actually
|
||
// holding an active STI certificate from iConectiv.
|
||
//
|
||
// The authoritative source for certificate verification is the STI-PA
|
||
// at authenticate.iconectiv.com, which tracks actual certificate
|
||
// issuance by OCN. We flag the self-reported status and recommend
|
||
// the carrier confirm their cert is active with their STI-CA.
|
||
let ssStatus: "green" | "yellow" | "red" | "unknown" = "unknown";
|
||
let ssDetail = "";
|
||
if (rmdResult.found && rmdResult.implementation_type) {
|
||
const impl = rmdResult.implementation_type.toLowerCase();
|
||
const certDate = rmdResult.certification_date || "";
|
||
if (impl.includes("complete") || impl.includes("full")) {
|
||
ssStatus = "green";
|
||
ssDetail =
|
||
`Full STIR/SHAKEN implementation self-reported in RMD` +
|
||
(certDate ? ` (certified ${certDate})` : "") +
|
||
`. Note: this is the carrier's filing with the FCC, not a ` +
|
||
`verification from the STI-PA (iConectiv). Confirm your STI ` +
|
||
`certificate is active with your Certificate Authority and ` +
|
||
`that your SPC token has not expired.`;
|
||
} else if (impl.includes("partial") || impl.includes("robocall mitigation")) {
|
||
ssStatus = "yellow";
|
||
ssDetail =
|
||
`Partial implementation — robocall mitigation plan filed ` +
|
||
`but full STIR/SHAKEN not certified in RMD` +
|
||
(certDate ? ` (last updated ${certDate})` : "") +
|
||
`. Full implementation requires an STI certificate from an ` +
|
||
`approved CA (e.g. Sievert Larsen, Neustar, TransNexus) and ` +
|
||
`registration with the STI-PA at authenticate.iconectiv.com.`;
|
||
} else {
|
||
ssStatus = "yellow";
|
||
ssDetail = `Implementation type: ${rmdResult.implementation_type}. Verify certificate status with your STI-CA.`;
|
||
}
|
||
} else if (rmdResult.found) {
|
||
ssStatus = "red";
|
||
ssDetail =
|
||
`RMD filing exists but no STIR/SHAKEN implementation type ` +
|
||
`reported. If you are a voice service provider, you must ` +
|
||
`either implement STIR/SHAKEN (obtain an STI certificate ` +
|
||
`from an approved CA) or file a robocall mitigation plan.`;
|
||
} else {
|
||
ssDetail = "Cannot determine — no RMD filing found for this FRN. Verify STIR/SHAKEN status directly.";
|
||
}
|
||
|
||
// STIR/SHAKEN — computed but NOT shown on the compliance checker.
|
||
// The self-reported RMD status doesn't reliably indicate compliance,
|
||
// and showing it creates confusion with OCN bundling on the order page.
|
||
// Kept computed for internal use / future STI-PA verification.
|
||
// checks.push({
|
||
// id: "stir_shaken",
|
||
// label: "STIR/SHAKEN Call Authentication",
|
||
// status: ssStatus,
|
||
// detail: ssDetail,
|
||
// action_url: null,
|
||
// due_date: null,
|
||
// });
|
||
|
||
// 4. CPNI Annual Certification
|
||
//
|
||
// CPNI rule: by March 1 each year, every carrier must certify its
|
||
// compliance for the *prior* calendar year (47 CFR § 64.2009(e)).
|
||
// So on any date in year Y:
|
||
// - The "required cert year" is Y-1 (e.g. in 2026 you're certifying
|
||
// your 2025 compliance).
|
||
// - That cert's deadline is March 1 of year Y.
|
||
// - After March 1 of year Y with no filing → PAST DUE for Y-1;
|
||
// the missed deadline is the one we surface (NOT bumped a year
|
||
// forward — that would make a late filer look on-time).
|
||
// - Once the required cert is on file, the NEXT deadline is
|
||
// March 1 of year Y+1 for certifying year Y revenue.
|
||
const now = new Date();
|
||
const has499Filer = local499Result !== null;
|
||
const isForeignProvider = localRmdResult?.foreign_voice_provider === true;
|
||
|
||
// Required cert year = prior calendar year.
|
||
const requiredCertYear = now.getFullYear() - 1;
|
||
// Deadline for the required cert = March 1 of the current year.
|
||
const requiredCertDeadline = new Date(now.getFullYear(), 2, 1);
|
||
// Next annual deadline (only meaningful once the required cert is filed).
|
||
const nextAnnualDeadline = new Date(now.getFullYear() + 1, 2, 1);
|
||
|
||
const daysTo = (d: Date) =>
|
||
Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||
|
||
const requiredDeadlineIso = requiredCertDeadline.toISOString().slice(0, 10);
|
||
const nextDeadlineIso = nextAnnualDeadline.toISOString().slice(0, 10);
|
||
|
||
let cpniStatus: "green" | "yellow" | "red" | "unknown" = "unknown";
|
||
let cpniDetail = "";
|
||
let cpniDueDate = requiredDeadlineIso;
|
||
|
||
if (has499Filer && cpniResult?.filed) {
|
||
if (cpniResult.cert_year! >= requiredCertYear) {
|
||
// They've certified for the required year or later. Next deadline
|
||
// is the following March 1.
|
||
cpniStatus = "green";
|
||
cpniDueDate = nextDeadlineIso;
|
||
cpniDetail =
|
||
`Filer ID: ${filerId}. Filed for ${cpniResult.cert_year} ` +
|
||
`on ${cpniResult.date_filed}. Next due ${nextDeadlineIso}.`;
|
||
} else {
|
||
// Older cert on record — they're past due for the most recent year.
|
||
cpniStatus = "red";
|
||
cpniDueDate = requiredDeadlineIso;
|
||
cpniDetail =
|
||
`Filer ID: ${filerId}. Past due — last filed for ` +
|
||
`${cpniResult.cert_year} (${cpniResult.date_filed}). ` +
|
||
`${requiredCertYear} certification was required by ${requiredDeadlineIso}.`;
|
||
}
|
||
} else if (has499Filer) {
|
||
// No filing on record. If the required deadline is behind us, it's
|
||
// past due; otherwise it's upcoming.
|
||
if (requiredCertDeadline < now) {
|
||
const daysOverdue = -daysTo(requiredCertDeadline);
|
||
cpniStatus = "red";
|
||
cpniDueDate = requiredDeadlineIso;
|
||
cpniDetail =
|
||
`Filer ID: ${filerId}. PAST DUE — ${requiredCertYear} ` +
|
||
`certification was required by ${requiredDeadlineIso} ` +
|
||
`(${daysOverdue} days overdue). File immediately to reduce ` +
|
||
`47 CFR § 1.80 forfeiture exposure.`;
|
||
} else {
|
||
const daysUntil = daysTo(requiredCertDeadline);
|
||
if (daysUntil <= 30) {
|
||
cpniStatus = "yellow";
|
||
cpniDetail =
|
||
`Filer ID: ${filerId}. Due in ${daysUntil} days ` +
|
||
`(${requiredDeadlineIso}). Certifying ${requiredCertYear} compliance.`;
|
||
} else {
|
||
cpniStatus = "red";
|
||
cpniDetail =
|
||
`Filer ID: ${filerId}. No CPNI filing found. ` +
|
||
`${requiredCertYear} certification required by ${requiredDeadlineIso}.`;
|
||
}
|
||
}
|
||
} else {
|
||
if (isForeignProvider) {
|
||
cpniStatus = "green";
|
||
cpniDetail = "Not required — foreign voice providers operating outside the US are generally exempt from CPNI certification requirements.";
|
||
} else {
|
||
cpniDetail = `No 499 Filer ID found. If you are a US-based voice carrier, you may need to register with USAC and file CPNI certification by ${requiredDeadlineIso}. Foreign providers are typically exempt.`;
|
||
}
|
||
}
|
||
|
||
checks.push({
|
||
id: "cpni_certification",
|
||
label: "CPNI Annual Certification",
|
||
status: cpniStatus,
|
||
detail: cpniDetail,
|
||
action_url: null,
|
||
due_date: cpniDueDate,
|
||
});
|
||
|
||
// 5. Form 499-A
|
||
//
|
||
// Annual 499-A is due April 1 each year, reporting the PRIOR calendar
|
||
// year's revenue (47 CFR § 54.711). Same past-due pattern as CPNI:
|
||
// surface the MISSED deadline when no registration is on file past
|
||
// April 1, not a year-bumped future date.
|
||
const requiredFilingYear = now.getFullYear() - 1; // reporting year
|
||
const requiredFilingDue = new Date(now.getFullYear(), 3, 1); // Apr 1
|
||
const nextAnnualFilingDue = new Date(now.getFullYear() + 1, 3, 1);
|
||
const requiredFilingIso = requiredFilingDue.toISOString().slice(0, 10);
|
||
const nextFilingIso = nextAnnualFilingDue.toISOString().slice(0, 10);
|
||
|
||
let f499Status: "green" | "yellow" | "red" | "unknown" = "unknown";
|
||
let f499Detail = "";
|
||
let f499DueDate = requiredFilingIso;
|
||
const currentAsOf = filerDetail?.current_as_of || null;
|
||
|
||
if (has499Filer) {
|
||
if (currentAsOf) {
|
||
// Parse "Registration Current as of" (M/D/YYYY). If the date is
|
||
// on/after this year's April 1 (or after the reporting year's
|
||
// April 1 before we've hit April), the filing is current.
|
||
const regDate = new Date(currentAsOf);
|
||
if (regDate >= requiredFilingDue) {
|
||
f499Status = "green";
|
||
f499DueDate = nextFilingIso;
|
||
f499Detail =
|
||
`Filer ID: ${filerId}. Registration current as of ` +
|
||
`${currentAsOf}. Next due ${nextFilingIso}.`;
|
||
} else {
|
||
// Registration date is before the current filing deadline.
|
||
// They filed for a prior year but haven't filed the current one.
|
||
// Compute which year the last filing likely covered:
|
||
// a filing on 4/1/2025 covers CY2024 revenue.
|
||
const lastFiledYear = regDate.getFullYear() - 1;
|
||
const daysOverdue = requiredFilingDue < now
|
||
? Math.ceil((now.getTime() - requiredFilingDue.getTime()) / (1000 * 60 * 60 * 24))
|
||
: 0;
|
||
f499Status = requiredFilingDue < now ? "red" : "yellow";
|
||
f499DueDate = requiredFilingIso;
|
||
f499Detail = requiredFilingDue < now
|
||
? `Filer ID: ${filerId}. PAST DUE — last filing covered CY${lastFiledYear} ` +
|
||
`(filed ${currentAsOf}). The CY${requiredFilingYear} annual 499-A was due ` +
|
||
`${requiredFilingIso} (${daysOverdue} days overdue).`
|
||
: `Filer ID: ${filerId}. Last filing covered CY${lastFiledYear} ` +
|
||
`(filed ${currentAsOf}). CY${requiredFilingYear} 499-A due ${requiredFilingIso}.`;
|
||
}
|
||
} else if (requiredFilingDue < now) {
|
||
// No "current as of" date + deadline passed → past due.
|
||
const daysOverdue = Math.ceil(
|
||
(now.getTime() - requiredFilingDue.getTime()) / (1000 * 60 * 60 * 24),
|
||
);
|
||
f499Status = "red";
|
||
f499DueDate = requiredFilingIso;
|
||
f499Detail =
|
||
`Filer ID: ${filerId}. PAST DUE — ${requiredFilingYear} annual ` +
|
||
`499-A required by ${requiredFilingIso} (${daysOverdue} days overdue). ` +
|
||
`Could not verify filing status; file immediately.`;
|
||
} else {
|
||
const daysUntil = Math.ceil(
|
||
(requiredFilingDue.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||
);
|
||
if (daysUntil <= 30) {
|
||
f499Status = "yellow";
|
||
f499DueDate = requiredFilingIso;
|
||
f499Detail =
|
||
`Due in ${daysUntil} days (${requiredFilingIso}). Filer ID: ${filerId}.`;
|
||
} else {
|
||
f499Status = "unknown";
|
||
f499DueDate = requiredFilingIso;
|
||
f499Detail =
|
||
`Filer ID: ${filerId}. Could not verify filing status. ` +
|
||
`Next due ${requiredFilingIso}.`;
|
||
}
|
||
}
|
||
} else {
|
||
if (isForeignProvider) {
|
||
f499Status = "green";
|
||
f499Detail = "Not required — foreign voice providers are generally exempt from FCC Form 499 filing and USF contribution obligations.";
|
||
} else {
|
||
f499Detail = `No 499 Filer ID found for this FRN. If you are a US-based carrier, you must register with USAC and file Form 499-A annually (next due ${requiredFilingIso}). Foreign providers are typically exempt.`;
|
||
}
|
||
}
|
||
|
||
checks.push({
|
||
id: "form_499a",
|
||
label: "FCC Form 499-A (Annual)",
|
||
status: f499Status,
|
||
detail: f499Detail,
|
||
action_url: null,
|
||
due_date: f499DueDate,
|
||
});
|
||
|
||
// 6. Form 499-Q (quarterly)
|
||
//
|
||
// Quarterly projections are due Feb 1 / May 1 / Aug 1 / Nov 1 each
|
||
// year. Required for USF contributors (waived for de-minimis filers).
|
||
// We don't track per-quarter filings in our DB, so we surface BOTH the
|
||
// most recently passed deadline (so the customer can self-check past
|
||
// due) and the next upcoming deadline.
|
||
const quarterDues = [
|
||
new Date(now.getFullYear() - 1, 10, 1), // prior Nov 1 (for Jan–Feb window)
|
||
new Date(now.getFullYear(), 1, 1), // Feb 1
|
||
new Date(now.getFullYear(), 4, 1), // May 1
|
||
new Date(now.getFullYear(), 7, 1), // Aug 1
|
||
new Date(now.getFullYear(), 10, 1), // Nov 1
|
||
new Date(now.getFullYear() + 1, 1, 1), // next-year Feb 1
|
||
];
|
||
const next499Q = quarterDues.find(d => d > now)
|
||
?? new Date(now.getFullYear() + 1, 1, 1);
|
||
const last499Q = [...quarterDues].reverse().find(d => d <= now);
|
||
const days499QUntil = Math.ceil(
|
||
(next499Q.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||
);
|
||
const next499QIso = next499Q.toISOString().slice(0, 10);
|
||
const last499QIso = last499Q ? last499Q.toISOString().slice(0, 10) : null;
|
||
|
||
const isContributor = filerDetail?.contributor;
|
||
let q499Status: "green" | "yellow" | "red" | "unknown" = "unknown";
|
||
let q499Detail = "";
|
||
let q499DueDate = next499QIso;
|
||
|
||
// De minimis explainer — appended to 499-Q detail when relevant.
|
||
// The nuance most carriers miss: de minimis filers are EXEMPT from
|
||
// collecting and remitting USF to USAC, but they still OWE USF to
|
||
// their upstream vendors (the vendor charges it on their bill and
|
||
// remits it to USAC themselves). So de minimis doesn't mean "no USF
|
||
// cost" — it means you pay USF embedded in your vendor invoices
|
||
// instead of collecting a line-item surcharge from your own customers.
|
||
const DE_MINIMIS_NOTE =
|
||
`De minimis filers (estimated annual USF contribution < $10,000) ` +
|
||
`are exempt from filing 499-Q and from collecting USF surcharges ` +
|
||
`from end users. However, de minimis does NOT eliminate your USF ` +
|
||
`cost — your upstream carriers (IXCs, CLECs, VoIP providers) still ` +
|
||
`charge you USF on their invoices and remit it to USAC on your ` +
|
||
`behalf. The trade-off: filing as a full contributor lets you ` +
|
||
`pass the USF charge through to your customers as a line-item ` +
|
||
`surcharge (revenue-neutral); filing de minimis means you absorb ` +
|
||
`the cost yourself.`;
|
||
|
||
if (isForeignProvider && !has499Filer) {
|
||
q499Status = "green";
|
||
q499Detail = "Not required — foreign voice providers are generally exempt from USF quarterly projections.";
|
||
} else if (!has499Filer) {
|
||
// No 499 Filer ID — can't have a quarterly due if not registered with USAC
|
||
q499Status = "unknown";
|
||
q499Detail = "No 499 Filer ID found. 499-Q is only required for registered USF contributors. If you file 499-A as de minimis, 499-Q is waived.";
|
||
} else if (isContributor === false) {
|
||
// USAC records show this filer as a non-contributor.
|
||
q499Status = "green";
|
||
q499Detail =
|
||
`Not currently a USF contributor — 499-Q not required. ` +
|
||
`Next deadline ${next499QIso} if contribution status changes. ` +
|
||
DE_MINIMIS_NOTE;
|
||
} else if (f499Status === "red") {
|
||
// Annual 499-A is past due; quarterly is almost certainly overdue too.
|
||
q499Status = "red";
|
||
q499DueDate = last499QIso ?? next499QIso;
|
||
q499Detail =
|
||
`PAST DUE — 499-A is delinquent, so quarterly projections ` +
|
||
`(last deadline ${last499QIso}) are almost certainly overdue. ` +
|
||
`Next deadline ${next499QIso}.`;
|
||
} else if (days499QUntil <= 30) {
|
||
q499Status = "yellow";
|
||
q499Detail =
|
||
`Due in ${days499QUntil} days (${next499QIso}). ` +
|
||
`Not required if you elected de minimis on your 499-A. ` +
|
||
DE_MINIMIS_NOTE +
|
||
(last499QIso
|
||
? ` Confirm the ${last499QIso} quarterly was filed on time.`
|
||
: "");
|
||
} else {
|
||
q499Status = "unknown";
|
||
q499Detail =
|
||
`Next due ${next499QIso}. ` +
|
||
`Required for USF contributors; waived for de minimis filers. ` +
|
||
DE_MINIMIS_NOTE +
|
||
(last499QIso
|
||
? ` Most recent deadline was ${last499QIso} — if you're a ` +
|
||
`contributor, confirm that quarterly was filed.`
|
||
: "");
|
||
}
|
||
|
||
checks.push({
|
||
id: "form_499q",
|
||
label: "FCC Form 499-Q (Quarterly)",
|
||
status: q499Status,
|
||
detail: q499Detail,
|
||
action_url: null,
|
||
due_date: q499DueDate,
|
||
});
|
||
|
||
// 7. Broadband Data Collection (BDC / Form 477)
|
||
// Auto-flag likely broadband providers by comm type; show unknown for VoIP-only
|
||
const commType = (filerDetail?.comm_type || "").toLowerCase();
|
||
const likelyBroadband = [
|
||
"cellular", "pcs", "smr", "cable", "local exchange",
|
||
"ilec", "clec", "broadband", "isp", "internet",
|
||
"fiber", "fixed wireless", "satellite",
|
||
].some(t => commType.includes(t));
|
||
|
||
let bdcStatus: "green" | "yellow" | "red" | "unknown" = "unknown";
|
||
let bdcDetail = "";
|
||
if (isForeignProvider && !has499Filer) {
|
||
bdcStatus = "green";
|
||
bdcDetail = "Not required — BDC filing applies only to providers offering broadband or voice service within the United States.";
|
||
} else if (likelyBroadband) {
|
||
bdcStatus = "yellow";
|
||
bdcDetail = `Comm type "${filerDetail?.comm_type}" suggests broadband service. BDC filing likely required — confirm below.`;
|
||
} else if (has499Filer) {
|
||
bdcDetail = `Comm type "${filerDetail?.comm_type || "unknown"}". BDC filing is required if you provide broadband internet access OR retail voice service to end users in the United States.`;
|
||
} else {
|
||
bdcDetail = "BDC filing is required if you provide broadband internet access OR retail voice service to end users in the United States.";
|
||
}
|
||
|
||
checks.push({
|
||
id: "bdc_filing",
|
||
label: "BDC Filing (Broadband + Voice Subscription Data)",
|
||
status: bdcStatus,
|
||
detail: bdcDetail,
|
||
action_url: null,
|
||
due_date: null,
|
||
});
|
||
|
||
// 8. Foreign provider recommendation (skip for Canadian carriers — they're already in North America)
|
||
const providerCountry = (localRmdResult?.country || "").trim().toLowerCase();
|
||
if (isForeignProvider && providerCountry !== "canada") {
|
||
const country = localRmdResult?.country || "your country";
|
||
checks.push({
|
||
id: "foreign_carrier_recommendation",
|
||
label: "Expand Into North America",
|
||
status: "yellow",
|
||
detail:
|
||
`Your carrier is registered as a foreign voice provider (${country}). ` +
|
||
`Establishing a North American carrier entity can give you direct vendor relationships, ` +
|
||
`simplified billing with US/Canadian partners, and local number access. ` +
|
||
`We can help you launch a Canadian CRTC-licensed carrier (BC corporation + full CRTC registration) ` +
|
||
`or a US FCC-authorized carrier (state formation + CORES/FRN + RMD + 499-A + CPNI — everything included).`,
|
||
action_url: null,
|
||
due_date: null,
|
||
});
|
||
}
|
||
|
||
// 9. Corporation Good Standing (result already fetched in parallel phase 2)
|
||
const corpResult = p2.corpStatus as { found: boolean; entity_name: string; entity_type: string; status: string; state: string; years_behind: number; breakdown: string; principal_address: string } | null;
|
||
if (corpResult?.found) {
|
||
const cStatus = (corpResult.status || "").toUpperCase();
|
||
const corpSt = corpResult.state || "";
|
||
let corpCheckStatus: "green" | "yellow" | "red" | "unknown" = "unknown";
|
||
let corpDetail = "";
|
||
|
||
const stateAddr = corpResult.principal_address || "";
|
||
const coresAddr = coresResult.address || "";
|
||
let addrNote = "";
|
||
if (stateAddr && coresAddr) {
|
||
const normState = stateAddr.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 30);
|
||
const normCores = coresAddr.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 30);
|
||
if (normState && normCores && normState !== normCores) {
|
||
addrNote = ` Note: state filing address (${stateAddr}) differs from FCC CORES address (${coresAddr}) — verify which is current.`;
|
||
}
|
||
} else if (stateAddr) {
|
||
addrNote = ` Address on file: ${stateAddr}.`;
|
||
}
|
||
|
||
if (cStatus === "ACTIVE") {
|
||
corpCheckStatus = "green";
|
||
corpDetail = `${corpResult.entity_name} (${corpResult.entity_type || "entity"}) in ${corpSt} — Active / Good Standing.${addrNote}`;
|
||
} else if (cStatus === "DELINQUENT" || cStatus === "SUSPENDED") {
|
||
corpCheckStatus = "red";
|
||
corpDetail = `${corpResult.entity_name} in ${corpSt} shows status: ${cStatus}. ` +
|
||
(corpResult.years_behind > 0
|
||
? `You are ${corpResult.years_behind} year${corpResult.years_behind > 1 ? "s" : ""} behind on annual reports. ${corpResult.breakdown}`
|
||
: `Annual report may be overdue. Contact us for a status review.`) +
|
||
addrNote;
|
||
} else if (cStatus === "DISSOLVED" || cStatus === "INACTIVE") {
|
||
corpCheckStatus = "red";
|
||
corpDetail = `${corpResult.entity_name} in ${corpSt} shows status: ${cStatus}. ` +
|
||
`Reinstatement required to restore good standing. ` +
|
||
(corpResult.breakdown || "Contact us for a reinstatement quote.") +
|
||
addrNote;
|
||
} else {
|
||
corpDetail = `${corpResult.entity_name} in ${corpSt} — status: ${cStatus}.${addrNote}`;
|
||
}
|
||
|
||
checks.push({
|
||
id: "corporation_status",
|
||
label: "Corporation Good Standing",
|
||
status: corpCheckStatus,
|
||
detail: corpDetail,
|
||
action_url: null,
|
||
due_date: null,
|
||
});
|
||
}
|
||
|
||
// Entity info (best available name — local sources preferred over CORES scrape)
|
||
const entityName = local499Result?.legal_name
|
||
|| coresResult.entity_name
|
||
|| rmdResult.business_name
|
||
|| localRmdResult?.business_name
|
||
|| null;
|
||
|
||
res.json({
|
||
frn,
|
||
entity_name: entityName,
|
||
cores: {
|
||
entity_name: coresResult.entity_name,
|
||
address: coresResult.address || filerDetail?.hq_address || null,
|
||
city: coresResult.city || filerDetail?.hq_city || null,
|
||
state: coresResult.state || filerDetail?.hq_state || null,
|
||
zip: coresResult.zip || filerDetail?.hq_zip || null,
|
||
red_light: coresResult.red_light,
|
||
error: coresResult.error,
|
||
},
|
||
rmd: {
|
||
found: rmdResult.found,
|
||
removed: rmdRemoved,
|
||
removal_reason: localRemovedResult?.removal_reason || null,
|
||
rmd_number: rmdResult.rmd_number || localRmdResult?.rmd_number || null,
|
||
certification_date: rmdResult.certification_date || localRmdResult?.cert_date || null,
|
||
implementation_type: rmdResult.implementation_type || localRmdResult?.implementation_type || null,
|
||
business_address: localRmdResult?.business_address || null,
|
||
contact_name: rmdResult.contact_name || localRmdResult?.robocall_contact_name || null,
|
||
other_dba_names: localRmdResult?.other_dba_names || null,
|
||
previous_dba_names: localRmdResult?.previous_dba_names || null,
|
||
other_frns: localRmdResult?.other_frns || null,
|
||
foreign_voice_provider: localRmdResult?.foreign_voice_provider ?? null,
|
||
provider_types: [
|
||
localRmdResult?.voice_service_provider ? "Voice Service Provider" : null,
|
||
localRmdResult?.gateway_provider ? "Gateway Provider" : null,
|
||
localRmdResult?.intermediate_provider ? "Intermediate Provider" : null,
|
||
].filter(Boolean),
|
||
error: rmdResult.error,
|
||
},
|
||
filer_499: local499Result ? {
|
||
filer_id: local499Result.filer_id,
|
||
legal_name: local499Result.legal_name,
|
||
trade_name: local499Result.trade_name,
|
||
state: local499Result.state,
|
||
service_type: local499Result.service_type,
|
||
current_as_of: filerDetail?.current_as_of || null,
|
||
comm_type: filerDetail?.comm_type || null,
|
||
usf_contributor: filerDetail?.contributor ?? null,
|
||
cpni_cert_year: cpniResult?.cert_year || null,
|
||
cpni_date_filed: cpniResult?.date_filed || null,
|
||
} : null,
|
||
checks,
|
||
checked_at: new Date().toISOString(),
|
||
});
|
||
|
||
// Log the check for analytics (non-blocking)
|
||
try {
|
||
const issueCount = checks?.filter((c: any) => c.status === "red" || c.status === "yellow").length || 0;
|
||
const worstSeverity = checks?.some((c: any) => c.status === "red") ? "critical"
|
||
: checks?.some((c: any) => c.status === "yellow") ? "major"
|
||
: checks?.some((c: any) => c.status === "green") ? "clean" : "clean";
|
||
const flaggedSlugs = checks
|
||
?.filter((c: any) => c.status === "red" || c.status === "yellow")
|
||
.map((c: any) => c.id || c.slug || "")
|
||
.filter(Boolean) || [];
|
||
const elapsed = Date.now() - startMs;
|
||
|
||
pool.query(
|
||
`INSERT INTO compliance_check_log (frn, entity_name, ip_address, user_agent, referrer, total_checks, issues_found, severity, check_slugs, response_ms)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||
[
|
||
frn,
|
||
entityName || null,
|
||
(req as any).clientIp || req.ip || null,
|
||
req.headers["user-agent"]?.slice(0, 200) || null,
|
||
req.headers.referer?.slice(0, 200) || null,
|
||
checks?.length || 0,
|
||
issueCount,
|
||
worstSeverity,
|
||
flaggedSlugs.length > 0 ? flaggedSlugs : null,
|
||
elapsed,
|
||
],
|
||
).catch(() => {}); // non-blocking, don't fail the response
|
||
} catch { /* ignore logging errors */ }
|
||
} catch (err) {
|
||
console.error("[fcc-lookup] Error:", err);
|
||
res.status(500).json({ error: "FCC lookup failed. Please try again." });
|
||
}
|
||
});
|
||
|
||
// ── Data fetchers ────────────────────────────────────────────────────────
|
||
|
||
async function fetchCoresData(frn: string): Promise<CoresData> {
|
||
// FCC CORES public search detail page — returns entity info without login
|
||
const url = `https://apps.fcc.gov/cores/searchDetail.do?frn=${frn}`;
|
||
try {
|
||
const resp = await fetch(url, {
|
||
signal: AbortSignal.timeout(10000),
|
||
headers: {
|
||
"Accept": "text/html,application/xhtml+xml",
|
||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||
},
|
||
redirect: "follow",
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
return { frn, entity_name: null, address: null, city: null, state: null, zip: null, status: null, red_light: null, error: `CORES returned ${resp.status}` };
|
||
}
|
||
|
||
const html = await resp.text();
|
||
|
||
// Parse th/td pairs from the CORES detail page.
|
||
// Format: <th>Entity Name:</th> \n <td>Falcon Broadband, LLC</td>
|
||
const fields: Record<string, string> = {};
|
||
const pairRegex = /<th[^>]*>([^<]+)<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/gi;
|
||
let match;
|
||
while ((match = pairRegex.exec(html)) !== null) {
|
||
const key = match[1].replace(/[:\s]+$/g, "").trim().toLowerCase();
|
||
const val = match[2].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||
if (val && val.length > 1 && val.length < 200) {
|
||
fields[key] = val;
|
||
}
|
||
}
|
||
|
||
const entityName = fields["entity name"] || fields["registration name"] || null;
|
||
|
||
// Parse address from "Contact Address" field.
|
||
// Raw HTML has <br> separating lines: "12920 SE 38th Street<br>Bellevue, WA 98006<br>United States"
|
||
// We need to split on <br> BEFORE stripping tags to preserve line structure.
|
||
let address: string | null = null;
|
||
let city: string | null = null;
|
||
let state: string | null = null;
|
||
let zip: string | null = null;
|
||
|
||
// Re-extract the raw contact address (with <br> tags intact) from HTML
|
||
const addrMatch = html.match(/<th[^>]*>Contact Address:<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/i);
|
||
if (addrMatch) {
|
||
// Split on <br> tags to get individual lines
|
||
const lines = addrMatch[1]
|
||
.split(/<br\s*\/?>/i)
|
||
.map(l => l.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim())
|
||
.filter(l => l.length > 0 && l !== "United States" && l !== "US");
|
||
|
||
if (lines.length >= 2) {
|
||
// First line(s) = street, last line with city/state/zip pattern
|
||
for (let i = lines.length - 1; i >= 0; i--) {
|
||
const csz = lines[i].match(/^(.+),\s*([A-Z]{2})\s+(\d{5}(?:-\d{4})?)$/);
|
||
if (csz) {
|
||
city = csz[1].trim();
|
||
state = csz[2];
|
||
zip = csz[3];
|
||
// Everything before this line is the street address
|
||
address = lines.slice(0, i).join(", ") || null;
|
||
break;
|
||
}
|
||
}
|
||
// Fallback: if no city/state/zip pattern found, use first line as address
|
||
if (!address && lines.length > 0) {
|
||
address = lines[0];
|
||
}
|
||
} else if (lines.length === 1) {
|
||
// Single line — try to parse city/state/zip from it
|
||
const csz = lines[0].match(/^(.+?)\s+([A-Za-z\s]+),\s*([A-Z]{2})\s+(\d{5}(?:-\d{4})?)$/);
|
||
if (csz) {
|
||
address = csz[1].trim();
|
||
city = csz[2].trim();
|
||
state = csz[3];
|
||
zip = csz[4];
|
||
} else {
|
||
address = lines[0];
|
||
}
|
||
}
|
||
} else if (fields["contact address"]) {
|
||
// Fallback to the already-stripped version
|
||
address = fields["contact address"];
|
||
}
|
||
|
||
// Red light is not available on the public page — needs authenticated check
|
||
// We'll get it from the Playwright-based red light scraper separately
|
||
const redLight = null;
|
||
|
||
if (!entityName && html.includes("password")) {
|
||
return { frn, entity_name: null, address: null, city: null, state: null, zip: null, status: "login_required", red_light: null, error: "CORES returned a login page for this FRN" };
|
||
}
|
||
|
||
return {
|
||
frn,
|
||
entity_name: entityName,
|
||
address,
|
||
city,
|
||
state,
|
||
zip,
|
||
status: entityName ? "found" : "not_found",
|
||
red_light: redLight,
|
||
error: null,
|
||
};
|
||
} catch (err: any) {
|
||
return { frn, entity_name: null, address: null, city: null, state: null, zip: null, status: null, red_light: null, error: err.message || "CORES unreachable" };
|
||
}
|
||
}
|
||
|
||
async function fetchRmdData(frn: string): Promise<RmdData> {
|
||
// FCC RMD — the ServiceNow API requires auth, so we primarily rely on our local DB.
|
||
// Try the public-facing RMD search page as a fallback to check if the FRN is listed.
|
||
// The RMD public table API: https://fccprod.servicenowservices.com/api/now/table/x_g_fmc_rmd_robocall_mitigation_database
|
||
const urls = [
|
||
`https://fccprod.servicenowservices.com/api/now/table/x_g_fmc_rmd_robocall_mitigation_database?sysparm_query=u_frn=${frn}&sysparm_limit=1&sysparm_fields=u_business_name,u_frn,number,u_certification_date,u_implementation_type,u_robocall_contact_name,u_robocall_contact_email`,
|
||
`https://fccprod.servicenowservices.com/api/x_g_fmc_rmd/rmd?frn=${frn}`,
|
||
];
|
||
|
||
for (const url of urls) {
|
||
try {
|
||
const resp = await fetch(url, {
|
||
signal: AbortSignal.timeout(10000),
|
||
headers: { "Accept": "application/json", "User-Agent": "Mozilla/5.0 (compatible; PerformanceWest/1.0)" },
|
||
});
|
||
|
||
if (!resp.ok) continue;
|
||
|
||
const data = await resp.json() as { result?: any[] };
|
||
const records = data.result || [];
|
||
if (records.length === 0) continue;
|
||
|
||
const rec = records[0];
|
||
return {
|
||
found: true,
|
||
business_name: rec.u_business_name || rec.business_name || rec.name || null,
|
||
frn: rec.u_frn || rec.frn || frn,
|
||
rmd_number: rec.number || rec.rmd_number || null,
|
||
certification_date: rec.u_certification_date || rec.certification_date || null,
|
||
implementation_type: rec.u_implementation_type || rec.implementation_type || null,
|
||
contact_name: rec.u_robocall_contact_name || null,
|
||
contact_email: rec.u_robocall_contact_email || null,
|
||
removed: false,
|
||
removal_reason: null,
|
||
error: null,
|
||
};
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// All API attempts failed — return not found (local DB will fill in if we have it)
|
||
return { found: false, business_name: null, frn: null, rmd_number: null, certification_date: null, implementation_type: null, contact_name: null, contact_email: null, removed: false, removal_reason: null, error: "RMD public API unavailable — checked local database" };
|
||
}
|
||
|
||
async function fetchLocalRmd(frn: string): Promise<any | null> {
|
||
try {
|
||
const result = await pool.query(
|
||
"SELECT * FROM fcc_rmd WHERE frn = $1 LIMIT 1",
|
||
[frn],
|
||
);
|
||
return result.rows[0] || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function fetchLocal499Filer(frn: string): Promise<any | null> {
|
||
try {
|
||
const result = await pool.query(
|
||
"SELECT * FROM fcc_499_filers WHERE frn = $1 LIMIT 1",
|
||
[frn],
|
||
);
|
||
return result.rows[0] || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function fetch499Detail(filerId: string): Promise<{
|
||
current_as_of: string | null; comm_type: string | null; contributor: boolean | null;
|
||
hq_address: string | null; hq_city: string | null; hq_state: string | null; hq_zip: string | null;
|
||
error: string | null;
|
||
}> {
|
||
// Scrape the FCC 499 filer detail page for filing status + address
|
||
const url = `https://apps.fcc.gov/cgb/form499/499detail.cfm?FilerNum=${filerId}`;
|
||
try {
|
||
const resp = await fetch(url, {
|
||
signal: AbortSignal.timeout(10000),
|
||
headers: { "Accept": "text/html", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" },
|
||
});
|
||
if (!resp.ok) return { current_as_of: null, comm_type: null, contributor: null, hq_address: null, hq_city: null, hq_state: null, hq_zip: null, error: `499 detail returned ${resp.status}` };
|
||
|
||
const html = await resp.text();
|
||
|
||
const currentMatch = html.match(/Registration Current as of:\s*<b>([^<]+)<\/b>/i);
|
||
const current_as_of = currentMatch ? currentMatch[1].trim() : null;
|
||
|
||
const commMatch = html.match(/Principal Communications Type:\s*<b>([^<]+)<\/b>/i);
|
||
const comm_type = commMatch ? commMatch[1].trim() || null : null;
|
||
|
||
const contribMatch = html.match(/Universal Service Fund Contributor:\s*<b>([^<]+)<\/b>/i);
|
||
const contributor = contribMatch ? contribMatch[1].trim().toLowerCase() === "yes" : null;
|
||
|
||
// Extract headquarters address (more reliable than CORES for some FRNs)
|
||
const addrMatch = html.match(/Headquarters Address:\s*<b>([^<]*)<\/b>/i);
|
||
const cityMatch = html.match(/Headquarters Address:[\s\S]*?City:\s*<b>([^<]*)<\/b>/i);
|
||
const stateMatch = html.match(/Headquarters Address:[\s\S]*?State:\s*<b>([^<]*)<\/b>/i);
|
||
const zipMatch = html.match(/Headquarters Address:[\s\S]*?ZIP Code:\s*<b>([^<]*)<\/b>/i);
|
||
|
||
return {
|
||
current_as_of, comm_type, contributor,
|
||
hq_address: addrMatch ? addrMatch[1].trim() || null : null,
|
||
hq_city: cityMatch ? cityMatch[1].trim() || null : null,
|
||
hq_state: stateMatch ? stateMatch[1].trim() || null : null,
|
||
hq_zip: zipMatch ? zipMatch[1].trim() || null : null,
|
||
error: null,
|
||
};
|
||
} catch (err: any) {
|
||
return { current_as_of: null, comm_type: null, contributor: null, hq_address: null, hq_city: null, hq_state: null, hq_zip: null, error: err.message };
|
||
}
|
||
}
|
||
|
||
async function fetchCpniStatus(filerId: string): Promise<{ filed: boolean; cert_year: number | null; date_filed: string | null; error: string | null }> {
|
||
// Check FCC CPNI portal for filing status
|
||
const url = "https://apps.fcc.gov/eb/CPNI/search_results.cfm";
|
||
try {
|
||
const resp = await fetch(url, {
|
||
method: "POST",
|
||
signal: AbortSignal.timeout(10000),
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||
},
|
||
body: `Form499FilerID=${filerId}&BeginDate=&EndDate=&ConfirmationNumbers=&SubmitForm=Find+Submissions`,
|
||
});
|
||
if (!resp.ok) return { filed: false, cert_year: null, date_filed: null, error: `CPNI returned ${resp.status}` };
|
||
|
||
const html = await resp.text();
|
||
|
||
// Parse rows: <td>Feb 28 2025 8:30PM</td> ... <td>2024</td>
|
||
// The HTML has tabs/newlines between td elements
|
||
const rowRegex = /<td>([A-Z][a-z]{2}\s+\d{1,2}\s+\d{4}[^<]*)<\/td>[\s\S]*?<td>(\d{4})<\/td>/gi;
|
||
let latestYear = 0;
|
||
let latestDate = "";
|
||
let match;
|
||
while ((match = rowRegex.exec(html)) !== null) {
|
||
const year = parseInt(match[2]);
|
||
if (year > latestYear) {
|
||
latestYear = year;
|
||
latestDate = match[1].trim();
|
||
}
|
||
}
|
||
|
||
if (latestYear > 0) {
|
||
return { filed: true, cert_year: latestYear, date_filed: latestDate, error: null };
|
||
}
|
||
|
||
// Check if there were no results
|
||
if (html.includes("No matching records")) {
|
||
return { filed: false, cert_year: null, date_filed: null, error: null };
|
||
}
|
||
|
||
return { filed: false, cert_year: null, date_filed: null, error: null };
|
||
} catch (err: any) {
|
||
return { filed: false, cert_year: null, date_filed: null, error: err.message };
|
||
}
|
||
}
|
||
|
||
async function fetchLocalRemoved(frn: string): Promise<any | null> {
|
||
try {
|
||
const result = await pool.query(
|
||
"SELECT * FROM fcc_rmd_removed WHERE frn = $1 LIMIT 1",
|
||
[frn],
|
||
);
|
||
return result.rows[0] || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Search local FCC databases by FRN, 499 Filer ID, or entity name.
|
||
*
|
||
* GET /api/v1/fcc/search?q=Falcon+Broadband
|
||
* GET /api/v1/fcc/search?frn=0027160886
|
||
* GET /api/v1/fcc/search?filer_id=812345
|
||
*
|
||
* Returns matching records from fcc_rmd, fcc_499_filers, and fcc_rmd_removed.
|
||
*/
|
||
/**
|
||
* Parse city + state from an RMD business_address string.
|
||
* The address is multi-line, with the last line typically:
|
||
* "Sheridan WY 82801" — City ST ZIP
|
||
* "UPSALA MN 56384" — City ST ZIP
|
||
* "Las Vegas NV 89104" — City ST ZIP
|
||
* "Quezon City, Philippines" — international (no US state)
|
||
*/
|
||
function addLocationFromAddress(row: Record<string, unknown>): Record<string, unknown> {
|
||
const addr = String(row.business_address || "");
|
||
if (!addr) return row;
|
||
// Split on newlines and take the last non-empty line
|
||
const lines = addr.split(/\n/).map(l => l.trim()).filter(Boolean);
|
||
const lastLine = lines[lines.length - 1] || "";
|
||
// Match "City ST ZIP" or "City ST" where ST is exactly 2 uppercase letters
|
||
const m = lastLine.match(/^(.+?)\s+([A-Z]{2})\s*(\d{5}(?:-\d{4})?)?$/);
|
||
if (m) {
|
||
row.city = m[1].replace(/,\s*$/, "").trim();
|
||
row.state = m[2];
|
||
}
|
||
return row;
|
||
}
|
||
|
||
router.get("/api/v1/fcc/search", async (req, res) => {
|
||
const q = (req.query.q as string || "").trim();
|
||
const frn = (req.query.frn as string || "").trim();
|
||
const filerId = (req.query.filer_id as string || "").trim();
|
||
|
||
if (!q && !frn && !filerId) {
|
||
res.status(400).json({ error: "Provide q (name search), frn, or filer_id." });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const results: any[] = [];
|
||
|
||
if (frn) {
|
||
// Exact FRN search across all tables
|
||
const rmd = await pool.query(
|
||
"SELECT rmd_number, frn, business_name, business_address, implementation, removed_from_rmd, removed_at, last_recertified, 'rmd' as source FROM fcc_rmd WHERE frn = $1 LIMIT 10",
|
||
[frn.padStart(10, "0")],
|
||
);
|
||
results.push(...rmd.rows.map(addLocationFromAddress));
|
||
|
||
const filers = await pool.query(
|
||
"SELECT filer_id, frn, legal_name as business_name, state, service_type, 'filer_499' as source FROM fcc_499_filers WHERE frn = $1 LIMIT 10",
|
||
[frn.padStart(10, "0")],
|
||
);
|
||
results.push(...filers.rows);
|
||
}
|
||
|
||
if (filerId) {
|
||
// Search by 499 Filer ID
|
||
const filers = await pool.query(
|
||
"SELECT filer_id, frn, legal_name as business_name, state, service_type, 'filer_499' as source FROM fcc_499_filers WHERE filer_id = $1 LIMIT 10",
|
||
[filerId],
|
||
);
|
||
results.push(...filers.rows);
|
||
}
|
||
|
||
if (q && q.length >= 2) {
|
||
// Free-text name search across both tables
|
||
const searchTerm = `%${q}%`;
|
||
|
||
const rmd = await pool.query(
|
||
`SELECT rmd_number, frn, business_name, business_address, implementation, removed_from_rmd, removed_at, 'rmd' as source
|
||
FROM fcc_rmd WHERE business_name ILIKE $1 ORDER BY business_name LIMIT 20`,
|
||
[searchTerm],
|
||
);
|
||
results.push(...rmd.rows.map(addLocationFromAddress));
|
||
|
||
const filers = await pool.query(
|
||
`SELECT filer_id, frn, legal_name as business_name, state, service_type, 'filer_499' as source
|
||
FROM fcc_499_filers WHERE legal_name ILIKE $1 OR trade_name ILIKE $1 ORDER BY legal_name LIMIT 20`,
|
||
[searchTerm],
|
||
);
|
||
results.push(...filers.rows);
|
||
}
|
||
|
||
// Deduplicate by FRN
|
||
const seen = new Set<string>();
|
||
const deduped = results.filter(r => {
|
||
const key = r.frn || r.rmd_number || r.filer_id || JSON.stringify(r);
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
|
||
res.json({
|
||
results: deduped,
|
||
count: deduped.length,
|
||
query: { q: q || undefined, frn: frn || undefined, filer_id: filerId || undefined },
|
||
});
|
||
} catch (err) {
|
||
console.error("[fcc-search] Error:", err);
|
||
res.status(500).json({ error: "Search failed." });
|
||
}
|
||
});
|
||
|
||
|
||
/**
|
||
* Search CORES / FCC 499 database by business name.
|
||
* Queries the FCC 499 filer database directly for entities matching the name.
|
||
*
|
||
* GET /api/v1/fcc/cores-search?name=Carrier+One
|
||
*/
|
||
router.get("/api/v1/fcc/cores-search", async (req, res) => {
|
||
const name = (req.query.name as string || "").trim();
|
||
if (!name || name.length < 2) {
|
||
res.status(400).json({ error: "Provide a business name (at least 2 characters)." });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Query FCC 499 filer database with wildcard search
|
||
// The FCC search engine can't handle & in names (treats as URL separator even when encoded)
|
||
// Strip & so "AT&T" becomes "ATT" which still matches their records
|
||
const sanitizedName = name.replace(/&/g, "");
|
||
const url = `https://apps.fcc.gov/cgb/form499/499results.cfm?FilerID=&frn=&LegalName=${encodeURIComponent(sanitizedName)}&state=Any+State&operational=&comm_type=Any+Type&R1=and`;
|
||
const resp = await fetch(url, {
|
||
signal: AbortSignal.timeout(15000),
|
||
headers: { "Accept": "text/html", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" },
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
res.status(502).json({ error: `The FCC search system is temporarily unavailable (HTTP ${resp.status}). Try searching by FRN instead, or try again in a few minutes.` });
|
||
return;
|
||
}
|
||
|
||
const html = await resp.text();
|
||
|
||
// FCC sometimes returns 200 but with a maintenance/error page
|
||
if (html.includes("Service Unavailable") || (html.includes("503") && !html.includes("499results"))) {
|
||
res.status(502).json({ error: "The FCC search system is temporarily unavailable. Try searching by FRN instead, or try again in a few minutes." });
|
||
return;
|
||
}
|
||
|
||
// Parse the results table rows: <tr><th scope="row">834314</th> ... <td><A HREF="499detail.cfm?FilerNum=834314">Carrier One Inc.</a></td><td>DBA</td></tr>
|
||
const rowRegex = /<tr><th scope="row">(\d+)<\/th>\s*\n?\s*<td><A HREF="499detail\.cfm\?FilerNum=\d+">([^<]+)<\/a><\/td><td>([^<]*)<\/td><\/tr>/gi;
|
||
const results: { filer_id: string; legal_name: string; dba: string }[] = [];
|
||
let match;
|
||
while ((match = rowRegex.exec(html)) !== null) {
|
||
results.push({
|
||
filer_id: match[1].trim(),
|
||
legal_name: match[2].trim(),
|
||
dba: match[3].trim(),
|
||
});
|
||
}
|
||
|
||
// Also extract record count
|
||
const countMatch = html.match(/(\d+)\s+Record(?:s)?\s+Found/i);
|
||
const totalCount = countMatch ? parseInt(countMatch[1]) : results.length;
|
||
|
||
// For each result, look up the FRN from our local database
|
||
const enriched = await Promise.all(results.map(async (r) => {
|
||
try {
|
||
const local = await pool.query(
|
||
"SELECT frn FROM fcc_499_filers WHERE filer_id = $1 LIMIT 1",
|
||
[r.filer_id],
|
||
);
|
||
return { ...r, frn: local.rows[0]?.frn || null };
|
||
} catch {
|
||
return { ...r, frn: null };
|
||
}
|
||
}));
|
||
|
||
res.json({
|
||
results: enriched,
|
||
count: enriched.length,
|
||
total_found: totalCount,
|
||
query: name,
|
||
source: "FCC Form 499 Filer Database",
|
||
});
|
||
} catch (err: any) {
|
||
// FCC is down or blocking us — fall back to local database
|
||
console.warn("[fcc-cores-search] FCC search failed, falling back to local DB:", err.message);
|
||
try {
|
||
const localResults = await pool.query(
|
||
`SELECT filer_id, legal_name, trade_name AS dba, frn
|
||
FROM fcc_499_filers
|
||
WHERE legal_name ILIKE $1 OR trade_name ILIKE $1
|
||
ORDER BY legal_name
|
||
LIMIT 50`,
|
||
[`%${name}%`],
|
||
);
|
||
const enriched = localResults.rows.map((r: any) => ({
|
||
filer_id: r.filer_id,
|
||
legal_name: r.legal_name,
|
||
dba: r.dba || "",
|
||
frn: r.frn || null,
|
||
}));
|
||
res.json({
|
||
results: enriched,
|
||
count: enriched.length,
|
||
total_found: enriched.length,
|
||
query: name,
|
||
source: "Local database (FCC search temporarily unavailable)",
|
||
});
|
||
} catch (dbErr: any) {
|
||
console.error("[fcc-cores-search] Local fallback also failed:", dbErr);
|
||
res.status(500).json({ error: "Search is temporarily unavailable. Please try again later or search by FRN." });
|
||
}
|
||
}
|
||
});
|
||
|
||
|
||
/**
|
||
* Upload FCC Form 499 Filer Database Excel dump.
|
||
*
|
||
* POST /api/v1/fcc/upload-filer-db
|
||
* Content-Type: multipart/form-data (file field: "file")
|
||
*
|
||
* Accepts the Excel dump from https://apps.fcc.gov/cgb/form499/499a.cfm
|
||
* and upserts all records into fcc_499_filers table.
|
||
*/
|
||
router.post("/api/v1/fcc/upload-filer-db", async (req, res) => {
|
||
// Accept raw body as the file content (simple approach — no multer needed)
|
||
// Called via: curl -X POST -F "file=@499_dump.xlsx" .../upload-filer-db
|
||
// For now, accept JSON with CSV-parsed rows from the client side
|
||
const { rows } = req.body ?? {};
|
||
|
||
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||
res.status(400).json({
|
||
error: "Send JSON body with 'rows' array. Each row: {filer_id, frn, legal_name, state, service_type}",
|
||
hint: "Use the CLI importer: python3 -m scripts.workers.fcc_499_filer_import /path/to/499_dump.xlsx",
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let count = 0;
|
||
for (const row of rows) {
|
||
const filerId = String(row.filer_id || row.FilerID || "").trim();
|
||
const legalName = String(row.legal_name || row.LegalName || row.Company || "").trim();
|
||
if (!filerId || !legalName) continue;
|
||
|
||
await pool.query(
|
||
`INSERT INTO fcc_499_filers (filer_id, frn, legal_name, trade_name, state, service_type, status, last_scraped_at, updated_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||
ON CONFLICT (filer_id) DO UPDATE SET
|
||
frn = COALESCE(EXCLUDED.frn, fcc_499_filers.frn),
|
||
legal_name = EXCLUDED.legal_name,
|
||
trade_name = COALESCE(EXCLUDED.trade_name, fcc_499_filers.trade_name),
|
||
state = COALESCE(EXCLUDED.state, fcc_499_filers.state),
|
||
service_type = COALESCE(EXCLUDED.service_type, fcc_499_filers.service_type),
|
||
last_scraped_at = NOW(), updated_at = NOW()`,
|
||
[
|
||
filerId,
|
||
String(row.frn || row.FRN || row.CORESID || "").trim() || null,
|
||
legalName,
|
||
String(row.trade_name || row.TradeName || "").trim() || null,
|
||
String(row.state || row.State || "").trim().substring(0, 2) || null,
|
||
String(row.service_type || row.ServiceType || row.PrincipalCommunicationsType || "").trim() || null,
|
||
String(row.status || row.OperationalStatus || "Active").trim(),
|
||
],
|
||
);
|
||
count++;
|
||
}
|
||
|
||
const totalResult = await pool.query("SELECT COUNT(*) FROM fcc_499_filers");
|
||
res.json({
|
||
success: true,
|
||
imported: count,
|
||
total_in_db: parseInt(totalResult.rows[0].count),
|
||
});
|
||
} catch (err) {
|
||
console.error("[fcc-upload] Error:", err);
|
||
res.status(500).json({ error: "Import failed." });
|
||
}
|
||
});
|
||
|
||
|
||
/**
|
||
* Import 499-A data from USAC E-File
|
||
*
|
||
* POST /api/v1/fcc/import-499a
|
||
* Body: { filer_id: "812345" }
|
||
*
|
||
* Dispatches a worker job to scrape USAC E-File for the filer's
|
||
* most recent 499-A data. Returns the extracted entity info,
|
||
* service categories, and revenue lines.
|
||
*/
|
||
router.post("/api/v1/fcc/import-499a", async (req, res) => {
|
||
const { filer_id } = req.body ?? {};
|
||
|
||
if (!filer_id || typeof filer_id !== "string" || filer_id.length < 3) {
|
||
res.status(400).json({ error: "Please provide a valid USAC 499 Filer ID." });
|
||
return;
|
||
}
|
||
|
||
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
|
||
|
||
try {
|
||
// Dispatch to worker
|
||
const workerResp = await fetch(`${WORKER_URL}/jobs`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
action: "import_499a",
|
||
filer_id: filer_id.trim(),
|
||
}),
|
||
signal: AbortSignal.timeout(60000),
|
||
});
|
||
|
||
if (!workerResp.ok) {
|
||
const err = await workerResp.text();
|
||
console.error("[fcc-import] Worker error:", err);
|
||
res.status(502).json({
|
||
success: false,
|
||
error: "Import service unavailable. Please try again or enter data manually.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
const result = await workerResp.json();
|
||
res.json(result);
|
||
} catch (err: any) {
|
||
console.error("[fcc-import] Error:", err.message);
|
||
|
||
// If worker is unavailable, return a helpful message
|
||
if (err.message?.includes("fetch failed") || err.message?.includes("ECONNREFUSED")) {
|
||
res.status(503).json({
|
||
success: false,
|
||
error: "Import service is not running. Please enter your data manually or contact us for assistance.",
|
||
manual_entry_required: true,
|
||
});
|
||
} else {
|
||
res.status(500).json({
|
||
success: false,
|
||
error: "Import failed. Please try again or enter data manually.",
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
export default router;
|