← 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); });