[object Object]

← back to AbramsOS

recall-watch.js: nightly-ready FDA recall watcher with PROGRAMMATIC lot/date relevance (per-fill), new-alert detection + state baseline; caught fluticasone D-0326-2024 overlapping a 2026 fill

362c884ad3395a7940c5669ab7c446661861d8a8 · 2026-07-08 15:47:47 -0700 · Steve

Files touched

Diff

commit 362c884ad3395a7940c5669ab7c446661861d8a8
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 8 15:47:47 2026 -0700

    recall-watch.js: nightly-ready FDA recall watcher with PROGRAMMATIC lot/date relevance (per-fill), new-alert detection + state baseline; caught fluticasone D-0326-2024 overlapping a 2026 fill
---
 data/recall-alerts.jsonl     |   2 +
 data/recall-watch-state.json |  25 ++++++++
 scripts/recall-watch.js      | 145 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 172 insertions(+)

diff --git a/data/recall-alerts.jsonl b/data/recall-alerts.jsonl
new file mode 100644
index 0000000..d505454
--- /dev/null
+++ b/data/recall-alerts.jsonl
@@ -0,0 +1,2 @@
+{"ndc":"00032-3016-13","drug":"CREON","recall":"D-0402-2021","class":"Class II","status":"confirm","at":"2026-07-08T22:46:31.161Z"}
+{"ndc":"60505-0829-01","drug":"FLUTICASONE PROP 50 MCG SPR","recall":"D-0326-2024","class":"Class II","status":"possible","at":"2026-07-08T22:46:33.007Z"}
diff --git a/data/recall-watch-state.json b/data/recall-watch-state.json
new file mode 100644
index 0000000..cd93abe
--- /dev/null
+++ b/data/recall-watch-state.json
@@ -0,0 +1,25 @@
+{
+ "seen": {
+  "00032-3016-13|D-0402-2021": {
+   "status": "confirm",
+   "class": "Class II",
+   "drug": "CREON"
+  },
+  "00121-0868-16|D-0721-2021": {
+   "status": "cleared",
+   "class": "Class II",
+   "drug": "NYSTATIN"
+  },
+  "60505-0829-01|D-0326-2024": {
+   "status": "possible",
+   "class": "Class II",
+   "drug": "FLUTICASONE PROP 50 MCG SPR"
+  },
+  "60505-2579-08|D-0153-2020": {
+   "status": "cleared",
+   "class": "Class III",
+   "drug": "ATORVASTATIN 20 MG TABLET"
+  }
+ },
+ "ran_at": "2026-07-08T22:46:45.031Z"
+}
\ No newline at end of file
diff --git a/scripts/recall-watch.js b/scripts/recall-watch.js
new file mode 100644
index 0000000..ea0de18
--- /dev/null
+++ b/scripts/recall-watch.js
@@ -0,0 +1,145 @@
+#!/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 code_info / product_description free text
+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; };
+  let m; const re1 = /(\d{1,2})\s*([A-Z]{3})\s*(\d{4})/g;                 // 30SEP2022
+  while ((m = re1.exec(s))) if (m[2] in MON) push(new Date(Date.UTC(+m[3], MON[m[2]], +m[1])));
+  const re2 = /EXP[.:]*\s*(\d{1,2})\/(\d{4})/g;                          // Exp. 03/2022 (MM/YYYY)
+  while ((m = re2.exec(s))) push(new Date(Date.UTC(+m[2], +m[1], 0)));   // end of that month
+  const re3 = /EXP[.:]*\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/g;               // 09/30/2026
+  while ((m = re3.exec(s))) push(new Date(Date.UTC(+m[3], +m[1] - 1, +m[2])));
+  const re4 = /([A-Z]{3})\s*(\d{4})/g;                                   // SEP 2022
+  while ((m = re4.exec(s))) if (m[1] in MON) push(new Date(Date.UTC(+m[2], MON[m[1]] + 1, 0)));
+  return best;
+}
+function assess(fill, init, exp) {
+  const F = new Date(fill); if (isNaN(F)) return { status: 'confirm', reason: 'no fill date' };
+  if (exp && !isNaN(exp)) {
+    if (exp < F) return { status: 'cleared', reason: `recalled lot expired ${exp.toISOString().slice(0, 10)}, before this ${F.toISOString().slice(0, 10)} fill — different lot.` };
+    if (init && init <= new Date(+F + 183 * DAY)) return { status: 'possible', reason: `recalled lot was in-date (exp ${exp.toISOString().slice(0, 10)}) at your ${F.toISOString().slice(0, 10)} fill — confirm your lot #.` };
+    return { status: 'cleared', reason: `recall postdates this fill.` };
+  }
+  if (init) {
+    if (init >= new Date(+F - 913 * DAY) && init <= new Date(+F + 183 * DAY))
+      return { status: 'confirm', reason: `recall (${init.toISOString().slice(0, 10)}) is near your ${F.toISOString().slice(0, 10)} fill and no lot expiry is published — confirm your lot #.` };
+    return { status: 'cleared', reason: `recall (${init.toISOString().slice(0, 10)}) is far from your ${F.toISOString().slice(0, 10)} fill.` };
+  }
+  return { status: 'confirm', reason: 'recall dates unavailable — confirm your lot #.' };
+}
+const worse = (a, b) => { const rank = { possible: 3, confirm: 2, cleared: 1 }; return (rank[a] || 0) >= (rank[b] || 0) ? a : b; };
+
+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]);
+
+  for (const [ndc, ndcFills] of byNdc) {
+    const recs = await fetchRecallsForNdc(ndc);
+    if (!recs.length) continue;
+    flaggedNdcs++;
+    // pick the recall with the latest lot expiry (most likely to overlap) + capture worst class
+    let worstClassRank = 0, chosen = null, chosenExp = null;
+    const clsRank = { 'Class I': 3, 'Class II': 2, 'Class III': 1 };
+    for (const rec of recs) {
+      const exp = parseExpiry(`${rec.code_info || ''} ${rec.product_description || ''}`);
+      const cr = clsRank[rec.classification] || 0;
+      if (!chosen || (exp && (!chosenExp || exp > chosenExp)) || cr > worstClassRank) { chosen = rec; chosenExp = exp; worstClassRank = Math.max(worstClassRank, cr); }
+    }
+    const init = fdaDate(chosen.recall_initiation_date);
+    let ndcStatus = 'cleared';
+    for (const f of ndcFills) {
+      const a = assess(f.fill_date, init, chosenExp);
+      ndcStatus = worse(ndcStatus, a.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`,
+        [chosen.recall_number || null, chosen.classification || null,
+         (chosen.reason_for_recall || '').slice(0, 300), a.status, a.reason, f.id]);
+      flaggedFills++;
+    }
+    const key = `${ndc}|${chosen.recall_number}`;
+    seen[key] = { status: ndcStatus, class: chosen.classification, drug: ndcFills[0].drug_name };
+    // NEW alert = a recall we haven't seen for this NDC before, AND it's relevant (not cleared)
+    if (!prev.seen[key] && ndcStatus !== 'cleared') {
+      newAlerts.push({ ndc, drug: ndcFills[0].drug_name, recall: chosen.recall_number, class: chosen.classification, 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'); }
+
+  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.recall} (${a.class}, ${a.status})`)); }
+  else console.log('  ✓ no new relevant recalls since last run.');
+  process.exit(0);
+}
+main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });

← a1038a6 Recall lot/date check: mark all 4 NDC recalls CLEARED (recal  ·  back to AbramsOS  ·  recall-watch: apply contrarian FIX-FIRST — E-known+in-date=> 4ccbda5 →