feat(npi): healthcare marketing pages, nav dropdown, NPI lookup API + free tool + companion data migration/loader
This commit is contained in:
parent
f349d519c6
commit
4b0155542e
9 changed files with 1176 additions and 2 deletions
69
api/migrations/088_npi_compliance_data.sql
Normal file
69
api/migrations/088_npi_compliance_data.sql
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- NPI / Healthcare provider compliance lookup data.
|
||||
--
|
||||
-- Powers /tools/npi-compliance-check. The live identity/status comes from
|
||||
-- the free public NPPES Registry API at query time; these tables hold the
|
||||
-- *dateable hooks* and exclusion data that the public NPI API does NOT
|
||||
-- expose, all keyed by NPI:
|
||||
--
|
||||
-- npi_revalidation_due — CMS Medicare revalidation due dates (the flagship
|
||||
-- dateable hook; ~218k providers are PAST DUE).
|
||||
-- npi_exclusions — OIG LEIE exclusion list (matched by NPI when present).
|
||||
-- npi_optout — Medicare opt-out affidavits (end dates).
|
||||
--
|
||||
-- Source files (free, public):
|
||||
-- data.cms.gov Revalidation Due List, Medicare Opt Out
|
||||
-- oig.hhs.gov LEIE downloadable database
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS npi_revalidation_due (
|
||||
id SERIAL PRIMARY KEY,
|
||||
npi TEXT NOT NULL,
|
||||
enrollment_id TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
organization_name TEXT,
|
||||
enrollment_state TEXT,
|
||||
enrollment_type TEXT, -- CMS enrollment type code
|
||||
provider_type TEXT,
|
||||
specialty TEXT,
|
||||
revalidation_due_date DATE, -- NULL = "TBD" in CMS data
|
||||
adjusted_due_date DATE,
|
||||
reassign_to TEXT,
|
||||
receiving_reassignment TEXT,
|
||||
loaded_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_npi_reval_npi ON npi_revalidation_due (npi);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS npi_exclusions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
npi TEXT, -- '0000000000' / blank when OIG has none
|
||||
last_name TEXT,
|
||||
first_name TEXT,
|
||||
middle_name TEXT,
|
||||
business_name TEXT,
|
||||
general_category TEXT,
|
||||
specialty TEXT,
|
||||
state TEXT,
|
||||
exclusion_type TEXT, -- e.g. 1128a1
|
||||
exclusion_date DATE,
|
||||
reinstatement_date DATE, -- NULL when still excluded
|
||||
loaded_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_npi_excl_npi ON npi_exclusions (npi) WHERE npi IS NOT NULL AND npi <> '0000000000';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS npi_optout (
|
||||
id SERIAL PRIMARY KEY,
|
||||
npi TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
specialty TEXT,
|
||||
optout_effective_date DATE,
|
||||
optout_end_date DATE,
|
||||
state TEXT,
|
||||
loaded_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_npi_optout_npi ON npi_optout (npi);
|
||||
|
||||
-- Reuse the existing free-tool logging table (compliance_check_log) for NPI
|
||||
-- checks; no schema change needed there.
|
||||
|
|
@ -47,6 +47,7 @@ import portalEsignGenericRouter from "./routes/portal-esign-generic.js";
|
|||
import pucRouter from "./routes/puc.js";
|
||||
import fccCarrierRegRouter from "./routes/fcc-carrier-registration.js";
|
||||
import dotLookupRouter from "./routes/dot-lookup.js";
|
||||
import npiLookupRouter from "./routes/npi-lookup.js";
|
||||
import surveyRouter from "./routes/survey.js";
|
||||
import orderTimelineRouter from "./routes/order-timeline.js";
|
||||
|
||||
|
|
@ -122,6 +123,7 @@ app.use(foreignQualRouter);
|
|||
app.use(pucRouter);
|
||||
app.use(fccCarrierRegRouter);
|
||||
app.use(dotLookupRouter);
|
||||
app.use(npiLookupRouter);
|
||||
app.use(surveyRouter);
|
||||
app.use(orderTimelineRouter);
|
||||
app.use(adminCryptoRouter);
|
||||
|
|
|
|||
361
api/src/routes/npi-lookup.ts
Normal file
361
api/src/routes/npi-lookup.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* NPI / Healthcare provider compliance lookup.
|
||||
*
|
||||
* GET /api/v1/npi/lookup?npi=1234567893
|
||||
* GET /api/v1/npi/search?name=Acme+Clinic&state=CA
|
||||
*
|
||||
* Combines the free public NPPES Registry API (live identity + status) with
|
||||
* local CMS/OIG companion tables (revalidation due dates, exclusions, opt-out)
|
||||
* to produce a green/yellow/red compliance summary, mirroring dot-lookup.ts.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
const NPPES_BASE = "https://npiregistry.cms.hhs.gov/api/";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function nppesFetch(params: Record<string, string>): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 6000);
|
||||
try {
|
||||
const qs = new URLSearchParams({ version: "2.1", ...params }).toString();
|
||||
const resp = await fetch(`${NPPES_BASE}?${qs}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type CheckStatus = "green" | "yellow" | "red" | "unknown";
|
||||
|
||||
interface ComplianceCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
status: CheckStatus;
|
||||
detail: string;
|
||||
action_url?: string | null;
|
||||
}
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.round((a.getTime() - b.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
function fmtDate(d: Date | null): string {
|
||||
if (!d) return "—";
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nppesName(basic: any): string {
|
||||
if (!basic) return "Unknown provider";
|
||||
if (basic.organization_name) return basic.organization_name;
|
||||
const parts = [basic.first_name, basic.middle_name, basic.last_name].filter(Boolean);
|
||||
const name = parts.join(" ").trim();
|
||||
return name || basic.name || "Unknown provider";
|
||||
}
|
||||
|
||||
// ── Lookup by NPI ───────────────────────────────────────────────────
|
||||
|
||||
router.get("/api/v1/npi/lookup", async (req, res) => {
|
||||
const rawNpi = String(req.query.npi || "").replace(/\D/g, "");
|
||||
if (!/^\d{10}$/.test(rawNpi)) {
|
||||
res.status(400).json({ error: "Provide a valid 10-digit NPI." });
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
// 1) Live NPPES identity + status
|
||||
const nppes = await nppesFetch({ number: rawNpi });
|
||||
const result = nppes?.results?.[0] || null;
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({
|
||||
error: "NPI not found in NPPES.",
|
||||
npi: rawNpi,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const basic = result.basic || {};
|
||||
const name = nppesName(basic);
|
||||
const status = basic.status || null; // "A" active, "D"/null = deactivated
|
||||
const enumType = result.enumeration_type || null; // NPI-1 individual, NPI-2 org
|
||||
const lastUpdated = basic.last_updated ? new Date(basic.last_updated) : null;
|
||||
const enumerationDate = basic.enumeration_date ? new Date(basic.enumeration_date) : null;
|
||||
const deactivationDate = basic.deactivation_date ? new Date(basic.deactivation_date) : null;
|
||||
|
||||
const primaryTaxonomy = (result.taxonomies || []).find((t: any) => t.primary) || (result.taxonomies || [])[0] || null;
|
||||
const locationAddr = (result.addresses || []).find((a: any) => a.address_purpose === "LOCATION") || (result.addresses || [])[0] || null;
|
||||
const practiceState = locationAddr?.state || null;
|
||||
|
||||
// 2) Companion data joins (best-effort; tables may be empty pre-load)
|
||||
const [revalRes, exclRes, optoutRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT revalidation_due_date, adjusted_due_date, enrollment_type, specialty, enrollment_state
|
||||
FROM npi_revalidation_due WHERE npi = $1 ORDER BY id LIMIT 1`,
|
||||
[rawNpi]
|
||||
).catch(() => ({ rows: [] as any[] })),
|
||||
pool.query(
|
||||
`SELECT exclusion_type, exclusion_date, reinstatement_date, general_category
|
||||
FROM npi_exclusions
|
||||
WHERE npi = $1 AND npi <> '0000000000'
|
||||
ORDER BY exclusion_date DESC NULLS LAST LIMIT 1`,
|
||||
[rawNpi]
|
||||
).catch(() => ({ rows: [] as any[] })),
|
||||
pool.query(
|
||||
`SELECT optout_effective_date, optout_end_date, specialty
|
||||
FROM npi_optout WHERE npi = $1 ORDER BY optout_end_date DESC NULLS LAST LIMIT 1`,
|
||||
[rawNpi]
|
||||
).catch(() => ({ rows: [] as any[] })),
|
||||
]);
|
||||
|
||||
const reval = revalRes.rows[0] || null;
|
||||
const excl = exclRes.rows[0] || null;
|
||||
const optout = optoutRes.rows[0] || null;
|
||||
|
||||
const now = new Date();
|
||||
const checks: ComplianceCheck[] = [];
|
||||
|
||||
// ── Check 1: NPI active status ──────────────────────────────────
|
||||
if (status === "A" && !deactivationDate) {
|
||||
checks.push({
|
||||
id: "npi-active",
|
||||
label: "NPI registration",
|
||||
status: "green",
|
||||
detail: "Your NPI is active in NPPES.",
|
||||
action_url: null,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "npi-active",
|
||||
label: "NPI registration",
|
||||
status: "red",
|
||||
detail: deactivationDate
|
||||
? `Your NPI was deactivated on ${fmtDate(deactivationDate)}. You cannot bill until it is reactivated.`
|
||||
: "Your NPI is not active in NPPES. Reactivation is required before you can bill.",
|
||||
action_url: "/order/npi-reactivation",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 2: Medicare revalidation (the dateable hook) ──────────
|
||||
if (reval) {
|
||||
const due = reval.adjusted_due_date || reval.revalidation_due_date;
|
||||
if (!due) {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "yellow",
|
||||
detail: "CMS lists you as enrolled but has not yet set a revalidation due date (\"TBD\"). We can monitor it for you.",
|
||||
action_url: "/services/healthcare/npi-revalidation",
|
||||
});
|
||||
} else {
|
||||
const dueDate = new Date(due);
|
||||
const daysToD = daysBetween(dueDate, now);
|
||||
if (daysToD < 0) {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "red",
|
||||
detail: `Your Medicare revalidation was due ${fmtDate(dueDate)} — ${Math.abs(daysToD)} days ago. CMS may deactivate your billing privileges. File now.`,
|
||||
action_url: "/order/npi-revalidation",
|
||||
});
|
||||
} else if (daysToD <= 180) {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "yellow",
|
||||
detail: `Your Medicare revalidation is due ${fmtDate(dueDate)} — ${daysToD} days away. File before the deadline to avoid deactivation.`,
|
||||
action_url: "/order/npi-revalidation",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "green",
|
||||
detail: `Your next Medicare revalidation is due ${fmtDate(dueDate)}.`,
|
||||
action_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "unknown",
|
||||
detail: "We did not find an active Medicare revalidation record for this NPI. If you bill Medicare, confirm your enrollment status in PECOS.",
|
||||
action_url: "/services/healthcare/medicare-enrollment",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 3: OIG / SAM exclusion ────────────────────────────────
|
||||
if (excl && !excl.reinstatement_date) {
|
||||
checks.push({
|
||||
id: "exclusion",
|
||||
label: "OIG exclusion screening",
|
||||
status: "red",
|
||||
detail: `This NPI matches an active OIG exclusion (${excl.exclusion_type || "type unknown"}, excluded ${fmtDate(excl.exclusion_date ? new Date(excl.exclusion_date) : null)}). Excluded providers cannot bill federal healthcare programs.`,
|
||||
action_url: "/order/oig-sam-screening",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "exclusion",
|
||||
label: "OIG exclusion screening",
|
||||
status: "green",
|
||||
detail: "No active OIG exclusion found for this NPI. Annual screening of your staff is still recommended.",
|
||||
action_url: "/order/oig-sam-screening",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 4: NPPES record freshness ─────────────────────────────
|
||||
if (lastUpdated) {
|
||||
const staleDays = daysBetween(now, lastUpdated);
|
||||
if (staleDays > 365 * 2) {
|
||||
checks.push({
|
||||
id: "nppes-fresh",
|
||||
label: "NPPES record currency",
|
||||
status: "yellow",
|
||||
detail: `Your NPPES record was last updated ${fmtDate(lastUpdated)} — over 2 years ago. CMS requires updates within 30 days of any change. Re-attest to keep it current.`,
|
||||
action_url: "/order/nppes-update",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "nppes-fresh",
|
||||
label: "NPPES record currency",
|
||||
status: "green",
|
||||
detail: `Your NPPES record was last updated ${fmtDate(lastUpdated)}.`,
|
||||
action_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 5: Medicare opt-out ───────────────────────────────────
|
||||
if (optout && optout.optout_end_date) {
|
||||
const endDate = new Date(optout.optout_end_date);
|
||||
const daysToEnd = daysBetween(endDate, now);
|
||||
if (daysToEnd >= 0 && daysToEnd <= 365) {
|
||||
checks.push({
|
||||
id: "optout",
|
||||
label: "Medicare opt-out",
|
||||
status: "yellow",
|
||||
detail: `Your Medicare opt-out affidavit ends ${fmtDate(endDate)}. Opt-out auto-renews every 2 years unless you act; if you want to re-enroll, plan ahead.`,
|
||||
action_url: "/services/healthcare/medicare-enrollment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const redCount = checks.filter((c) => c.status === "red").length;
|
||||
const yellowCount = checks.filter((c) => c.status === "yellow").length;
|
||||
const greenCount = checks.filter((c) => c.status === "green").length;
|
||||
const severity = redCount > 0 ? "critical" : yellowCount > 0 ? "major" : "clean";
|
||||
|
||||
// Fire-and-forget logging (reuse compliance_check_log; NPI stored in frn col)
|
||||
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)`,
|
||||
[
|
||||
rawNpi,
|
||||
name,
|
||||
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || null,
|
||||
req.headers["user-agent"] || null,
|
||||
req.headers["referer"] || null,
|
||||
checks.length,
|
||||
redCount + yellowCount,
|
||||
severity,
|
||||
checks.filter((c) => c.status === "red" || c.status === "yellow").map((c) => c.id),
|
||||
Date.now() - startedAt,
|
||||
]
|
||||
).catch(() => {});
|
||||
|
||||
res.json({
|
||||
npi: rawNpi,
|
||||
name,
|
||||
enumeration_type: enumType,
|
||||
status,
|
||||
taxonomy: primaryTaxonomy
|
||||
? { code: primaryTaxonomy.code, desc: primaryTaxonomy.desc, state: primaryTaxonomy.state, license: primaryTaxonomy.license }
|
||||
: null,
|
||||
practice_state: practiceState,
|
||||
enumeration_date: enumerationDate ? fmtDate(enumerationDate) : null,
|
||||
last_updated: lastUpdated ? fmtDate(lastUpdated) : null,
|
||||
checks,
|
||||
summary: {
|
||||
red: redCount,
|
||||
yellow: yellowCount,
|
||||
green: greenCount,
|
||||
total: checks.length,
|
||||
},
|
||||
severity,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[npi-lookup] Error:", err);
|
||||
if (!res.headersSent) res.status(500).json({ error: "NPI lookup failed." });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Name search ─────────────────────────────────────────────────────
|
||||
|
||||
router.get("/api/v1/npi/search", async (req, res) => {
|
||||
const name = String(req.query.name || "").trim();
|
||||
const state = String(req.query.state || "").trim().toUpperCase();
|
||||
if (name.length < 2) {
|
||||
res.status(400).json({ error: "Provide a name (at least 2 characters)." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// NPPES supports org name and last name search; try organization first,
|
||||
// then individual last name. Wildcard with trailing * for partials.
|
||||
const params: Record<string, string> = { limit: "15" };
|
||||
if (state && state.length === 2) params.state = state;
|
||||
|
||||
// Heuristic: single token -> last_name; otherwise org name.
|
||||
const tokens = name.split(/\s+/);
|
||||
if (tokens.length === 1) {
|
||||
params.last_name = name + "*";
|
||||
} else {
|
||||
params.organization_name = name + "*";
|
||||
}
|
||||
|
||||
let nppes = await nppesFetch(params);
|
||||
// Fallback: if org search empty, try last name
|
||||
if (!nppes?.results?.length && !params.last_name) {
|
||||
delete params.organization_name;
|
||||
params.last_name = tokens[tokens.length - 1] + "*";
|
||||
nppes = await nppesFetch(params);
|
||||
}
|
||||
|
||||
const results = (nppes?.results || []).map((r: any) => {
|
||||
const b = r.basic || {};
|
||||
const loc = (r.addresses || []).find((a: any) => a.address_purpose === "LOCATION") || (r.addresses || [])[0] || {};
|
||||
const tax = (r.taxonomies || []).find((t: any) => t.primary) || (r.taxonomies || [])[0] || {};
|
||||
return {
|
||||
npi: r.number,
|
||||
name: nppesName(b),
|
||||
enumeration_type: r.enumeration_type,
|
||||
status: b.status,
|
||||
taxonomy: tax.desc || null,
|
||||
city: loc.city || null,
|
||||
state: loc.state || null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ query: name, count: results.length, results });
|
||||
} catch (err) {
|
||||
console.error("[npi-search] Error:", err);
|
||||
if (!res.headersSent) res.status(500).json({ error: "NPI search failed." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Add table
Add a link
Reference in a new issue