← back to AbramsOS
scripts/recall-watch.js
167 lines
#!/usr/bin/env node
// AbramsOS recall watcher — re-checks every dispensed NDC against openFDA drug-enforcement,
// then PROGRAMMATICALLY assesses relevance from the recall's lot expiration + initiation date
// vs each fill's date. An alert is raised ONLY when a recalled lot could actually have been in
// your bottle at fill time — so a new recall of a drug you take doesn't cry wolf unless it's
// genuinely your product AND your timeframe.
//
// openFDA = free ($0). Idempotent. Meant to run nightly (launchd) + on demand.
// Usage: node scripts/recall-watch.js
//
// Relevance rule (per fill F, days_supply D, recall initiation I, worst lot expiry E):
// • E known & E < F → CLEARED (recalled lot expired before this fill = different lot)
// • E known & E ≥ F & I ≤ F+6mo → POSSIBLE (that lot was still in-date at fill, recall around then)
// • E unknown & I in [F−2.5y, F+6mo] → CONFIRM (can't rule out without the lot #)
// • otherwise → CLEARED (recall long predates/postdates any fill)
const fs = require('fs');
const path = require('path');
const db = require('../lib/db');
const audit = require('../lib/audit');
const USER = 'user_steve', SOURCE = 'shangoo-pharmacy-tax-2023-2026';
const BASE = 'https://api.fda.gov/drug/enforcement.json';
const KEY = process.env.OPENFDA_API_KEY ? `&api_key=${process.env.OPENFDA_API_KEY}` : '';
const STATE = path.join(__dirname, '..', 'data', 'recall-watch-state.json');
const ALERTS = path.join(__dirname, '..', 'data', 'recall-alerts.jsonl');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DAY = 864e5;
const segPair = (ndc) => { const p = String(ndc || '').split('-'); return (p.length >= 2 && /^\d+$/.test(p[0]) && /^\d+$/.test(p[1])) ? `${+p[0]}|${+p[1]}` : null; };
const candidates = (ndc) => { const p = String(ndc).split('-'); if (p.length < 2) return []; const c = new Set([`${p[0]}-${p[1]}`]); if (p[0][0] === '0') c.add(`${p[0].replace(/^0/, '')}-${p[1]}`); return [...c]; };
const fdaDate = (d) => (d && d.length === 8 ? new Date(`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`) : null);
// parse the LATEST EXPIRATION date from recall free text. ANCHORED on an EXP/EXPIRE/USE-BY
// keyword so a manufacturing date ("MFG APR 2021") or a stray "MON YYYY" is never mistaken for
// an expiry (which would wrongly CLEAR a genuinely-recalled fill). Unanchored dates are ignored.
const MON = { JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11 };
function parseExpiry(text) {
const s = String(text || '').toUpperCase(); let best = null; const push = (d) => { if (d && !isNaN(d) && (!best || d > best)) best = d; };
// grab the token(s) immediately after each EXP/EXPIRE/EXPIRATION/USE BY marker
const segs = s.match(/(?:EXPIRATION|EXPIRES?|EXPIR\w*|EXP|USE\s*BY)[.:\s]*([A-Z0-9][A-Z0-9/.\- ]{3,14})/g) || [];
for (const seg of segs) {
const v = seg.replace(/^(?:EXPIRATION|EXPIRES?|EXPIR\w*|EXP|USE\s*BY)[.:\s]*/, '').trim();
let m;
if ((m = v.match(/^(\d{1,2})\s*([A-Z]{3})\s*(\d{4})/)) && m[2] in MON) push(new Date(Date.UTC(+m[3], MON[m[2]], +m[1]))); // 30SEP2022
else if ((m = v.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/))) push(new Date(Date.UTC(+m[3], +m[1] - 1, +m[2]))); // 09/30/2026
else if ((m = v.match(/^(\d{1,2})\/(\d{4})/))) push(new Date(Date.UTC(+m[2], +m[1], 0))); // 03/2022
else if ((m = v.match(/^(\d{4})-(\d{2})-(\d{2})/))) push(new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))); // 2022-10-31
else if ((m = v.match(/^([A-Z]{3})\s*(\d{4})/)) && m[1] in MON) push(new Date(Date.UTC(+m[2], MON[m[1]] + 1, 0))); // SEP 2022
}
return best;
}
const RANK = { possible: 3, confirm: 2, cleared: 1 };
const worse = (a, b) => ((RANK[a] || 0) >= (RANK[b] || 0) ? a : b);
function assess(fill, init, exp) {
const F = new Date(fill); if (isNaN(F)) return { status: 'confirm', reason: 'no fill date' };
const iso = (d) => d.toISOString().slice(0, 10);
if (exp && !isNaN(exp)) {
if (exp < F) return { status: 'cleared', reason: `recalled lot expired ${iso(exp)}, before this ${iso(F)} fill — a different lot.` };
// E >= F: that lot was still IN-DATE when this was dispensed, so it could have been in the
// bottle — regardless of when FDA later announced the recall (a defect found later was present
// when dispensed). Exposure is set by the lot window, not the FDA publish date.
return { status: 'possible', reason: `recalled lot was in-date (exp ${iso(exp)}) at your ${iso(F)} fill — check your lot # against the recall.` };
}
if (init) {
if (init >= new Date(+F - 913 * DAY) && init <= new Date(+F + 183 * DAY))
return { status: 'confirm', reason: `recall (${iso(init)}) is near your ${iso(F)} fill and no lot expiry is published — check your lot #.` };
return { status: 'cleared', reason: `recall (${iso(init)}) is far from your ${iso(F)} fill.` };
}
return { status: 'confirm', reason: 'recall dates unavailable — check your lot #.' };
}
async function fetchRecallsForNdc(ndc) {
const want = segPair(ndc); if (!want) return [];
const out = new Map();
for (const cand of candidates(ndc)) {
let results = [];
try { const r = await fetch(`${BASE}?search=openfda.product_ndc:%22${encodeURIComponent(cand)}%22&limit=30${KEY}`, { headers: { 'User-Agent': 'AbramsOS/1.0' } }); if (r.ok) results = (await r.json()).results || []; }
catch { /* transient */ }
for (const rec of results) {
const pn = (rec.openfda && rec.openfda.product_ndc) || [];
if (pn.some((x) => segPair(x) === want)) out.set(rec.recall_number, rec);
}
await sleep(160);
}
return [...out.values()];
}
async function main() {
await db.query(`ALTER TABLE prescription_fill
ADD COLUMN IF NOT EXISTS ndc_recalled boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS ndc_recall_number text, ADD COLUMN IF NOT EXISTS ndc_recall_class text,
ADD COLUMN IF NOT EXISTS ndc_recall_reason text, ADD COLUMN IF NOT EXISTS ndc_recall_status text,
ADD COLUMN IF NOT EXISTS ndc_recall_assessment text`);
const fills = (await db.query(
`SELECT id, ndc, fill_date, days_supply, drug_name FROM prescription_fill
WHERE user_id=$1 AND source=$2 AND ndc IS NOT NULL AND ndc<>'' AND ndc NOT LIKE '00000%'`, [USER, SOURCE])).rows;
const byNdc = new Map(); for (const f of fills) { (byNdc.get(f.ndc) || byNdc.set(f.ndc, []).get(f.ndc)).push(f); }
console.log(`recall-watch: ${byNdc.size} distinct NDCs, ${fills.length} fills`);
const prev = fs.existsSync(STATE) ? JSON.parse(fs.readFileSync(STATE, 'utf8')) : { seen: {} };
const seen = {}; const newAlerts = []; let flaggedFills = 0, flaggedNdcs = 0;
// reset the source's recall flags, then recompute
await db.query(`UPDATE prescription_fill SET ndc_recalled=false, ndc_recall_number=NULL, ndc_recall_class=NULL, ndc_recall_reason=NULL, ndc_recall_status=NULL, ndc_recall_assessment=NULL WHERE user_id=$1 AND source=$2`, [USER, SOURCE]);
const clsRank = { 'Class I': 3, 'Class II': 2, 'Class III': 1 };
const worseClass = (a, b) => ((clsRank[a] || 0) >= (clsRank[b] || 0) ? a : b);
for (const [ndc, ndcFills] of byNdc) {
const recs = await fetchRecallsForNdc(ndc);
if (!recs.length) continue;
flaggedNdcs++;
// pre-parse EVERY matching recall (don't collapse to one — a Class I must never hide behind a Class II)
const parsed = recs.map((rec) => ({ rec, exp: parseExpiry(`${rec.code_info || ''} ${rec.product_description || ''}`), init: fdaDate(rec.recall_initiation_date), cls: rec.classification }));
const worstClass = parsed.reduce((w, p) => worseClass(w, p.cls), null); // severity shown for the NDC (over all recalls)
let ndcStatus = 'cleared';
for (const f of ndcFills) {
// assess this fill against ALL matching recalls; keep the one with the worst relevance/class
let best = null;
for (const p of parsed) {
const a = assess(f.fill_date, p.init, p.exp);
if (!best || (RANK[a.status] || 0) > (RANK[best.status] || 0)
|| (a.status === best.status && (clsRank[p.cls] || 0) > (clsRank[best.cls] || 0))) best = { ...a, rec: p.rec, cls: p.cls };
}
ndcStatus = worse(ndcStatus, best.status);
await db.query(
`UPDATE prescription_fill SET ndc_recalled=true, ndc_recall_number=$1, ndc_recall_class=$2,
ndc_recall_reason=$3, ndc_recall_status=$4, ndc_recall_assessment=$5 WHERE id=$6`,
[best.rec.recall_number || null, worstClass, // show the WORST class across all matching recalls
(best.rec.reason_for_recall || '').slice(0, 300), best.status, best.reason, f.id]);
flaggedFills++;
}
const key = `${ndc}|${worstClass}`; // baseline keyed by NDC+severity (survives which single recall wins the row)
seen[key] = { status: ndcStatus, class: worstClass, drug: ndcFills[0].drug_name };
// NEW alert = an (NDC, severity) we haven't seen before AND it's date-relevant (not cleared)
if (!prev.seen[key] && ndcStatus !== 'cleared') {
newAlerts.push({ ndc, drug: ndcFills[0].drug_name, class: worstClass, status: ndcStatus, at: new Date().toISOString() });
}
}
fs.mkdirSync(path.dirname(STATE), { recursive: true });
fs.writeFileSync(STATE, JSON.stringify({ seen, ran_at: new Date().toISOString() }, null, 1));
if (newAlerts.length) {
for (const a of newAlerts) fs.appendFileSync(ALERTS, JSON.stringify(a) + '\n');
// surface it — a silent nightly health check is not an alert. Local macOS notification ($0,
// no external send). Email/SMS would be outward-facing (gated) — left for Steve to approve.
try {
const { execFileSync } = require('child_process');
const drugs = [...new Set(newAlerts.map((a) => a.drug))].slice(0, 3).join(', ');
const body = `${newAlerts.length} new FDA recall(s) may affect your fills: ${drugs}. Open AbramsOS → Recalls.`;
execFileSync('osascript', ['-e', `display notification "${body.replace(/"/g, "'")}" with title "AbramsOS · Rx recall" sound name "Basso"`]);
} catch { /* headless / no GUI — the JSONL + audit log still captured it */ }
}
await audit.log({ actorType: 'system', actorId: 'recall-watch', objectType: 'prescription_fill', objectId: SOURCE,
eventType: 'recall_watch_run', metadata: { medical: true, source: 'openFDA', ndcs_recalled: flaggedNdcs, fills_flagged: flaggedFills, new_alerts: newAlerts.length } });
const sum = {}; for (const k of Object.keys(seen)) { const s = seen[k].status; sum[s] = (sum[s] || 0) + 1; }
console.log(`\nDONE: ${flaggedNdcs} NDCs on a recall list · ${flaggedFills} fills assessed`);
console.log(` by relevance: ${Object.entries(sum).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`);
if (newAlerts.length) { console.log(` 🔔 ${newAlerts.length} NEW relevant alert(s):`); newAlerts.forEach((a) => console.log(` ${a.drug} — ${a.class}, ${a.status} (NDC ${a.ndc})`)); }
else console.log(' ✓ no new relevant recalls since last run.');
process.exit(0);
}
main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });