project-nomad/admin/app/controllers/drug_reference_controller.ts
caweis 77c36b3fef
Add offline FDA drug reference (labels, interaction view, conditions, remedies) (#1040)
* feat(drug-reference): offline FDA drug labels, conditions, and remedies

Adds an offline medical-reference feature with three coupled layers:

- Drug Reference: full-text search over openFDA drug-label indications,
  a detail page per label, and a side-by-side single-drug comparison view.
  A two-phase background pipeline downloads the openFDA label parts to the
  storage volume (resumable) and ingests them into the search table.
- Conditions ("When to use what"): a curated spine of first-aid situations
  that maps each situation to matching OTC drugs, each linking back to its
  Drug Reference detail page.
- Curated remedies: hand-authored natural and home-remedy entries drawn from
  US-government public-domain sources (NCCIH, CDC, MedlinePlus, FDA), shown
  with their source links and the same safety disclaimers as the rest of the
  feature.

The drug-reference and conditions layers are intentionally coupled: the drug
detail page shows the situations a drug treats, and the conditions controller
reads the same drug_labels table.

Safety surfaces ship as written. The amber SafetyBanner ("informational only,
not medical advice, not an FDA endorsement, not a drug-interaction checker, in
an emergency call emergency services") renders on the condition pages and the
search page; the detail and comparison pages carry their own "not a cross-drug
interaction checker" callout; and every page carries the openFDA CC0 source
citation and no-FDA-affiliation footer.

Wiring on this branch:
- start/routes.ts: the /drug-reference and /conditions page GETs plus their
  /api/* groups.
- commands/queue/work.ts: the drug-download and drug-ingest queues, both at
  concurrency 1. The two drug queues get a per-queue stall override
  (lockDuration 1_800_000, maxStalledCount 3) because each part is one long
  stream; every other queue keeps the existing 300000 default.
- inertia/pages/home.tsx: Drug Reference and "When to use what" tiles. The
  icon and display_order are a starting point, open to change.
- types/kv_store.ts: the two drugReference.* keys the pipeline reads and writes.
- package.json: yauzl and stream-json (plus their @types), used by the ingest
  job to stream the label JSON out of the downloaded zips.

The app reads the conditions and remedy data from the compiled TS constants in
app/data/; the repo-root collections/*.json files are the browseable mirrors.
The natural-remedies standalone test reads collections/natural_remedies.json to
assert the two stay in sync, so that file is also a test fixture.

The four standalone tests pass (drug_interactions, drug_ingest_status,
conditions, natural_remedies). tsc reports no errors in the feature code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(drug-reference): drop a fork-internal comment reference

* fix(drug-reference): address review — defer FULLTEXT, fallback remedies prop, strip fork refs

- migration: wrap the FULLTEXT ALTER in this.defer so it runs after the
  deferred createTable (was silently swallowed, index never created)
- controller: add remedies:[] to the index() error fallback (required prop)
- strip fork-internal issue/spec references from ported comments
- tsconfig: exclude tests/standalone (node --experimental-strip-types only)
- correct the varchar(768) byte-math comment; extend remedy-spine test

* fix(drug-reference): make the interaction comparison readable at five drugs

The comparison view laid its columns out on an equal-fraction CSS grid
(repeat(N, minmax(0, 1fr))), so each added drug shrank every column; at the
five-drug maximum the FDA interaction text was squeezed into unreadable slivers.

Lay the columns out with flex instead: full-width and stacked on phones, then
fixed-width columns that scroll sideways from the sm: breakpoint up, so they
never shrink below a readable width. Theme the columns with the same palette as
the rest of the page (they were on stock gray), and give the headers a fixed
min-height so columns line up when drug names wrap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(collections): route a 'dataset' tier resource to the drug pipeline

Add an optional `type` discriminator to SpecResource ('zim' | 'dataset',
absent == 'zim'), so the tier installer can carry a DB-ingested resource
alongside ZIM files. ZimService.downloadCategoryTier branches on it: a
'dataset' resource dispatches the existing FDA download+ingest pipeline
instead of RunDownloadJob, guarded against duplicate dispatch by the drug
ingest status. Every existing manifest entry has no `type` and keeps the
exact ZIM path.

Widen InstalledResource.resource_type to include 'dataset' and exclude
dataset rows from the ZIM/map catalog-update scan (datasets aren't
filename-versioned; their freshness path is separate). No dataset rows are
written yet: the InstalledResource 'dataset' row on ingest-ready, the
manifest entry, install-gating, and the downloads-aggregator integration
are follow-up commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): rework into an opt-in medicine-standard tier dataset

Reshapes the offline FDA drug reference from a built-in feature into curated
content installed by selecting the Medicine / Standard tier, per maintainer
direction.

- install-state: the ingest writes an installed_resources 'dataset' row on
  ready (version = the openFDA export_date), threaded installer to download to
  ingest; the tier-status math and the home-tile gate read it. Manual ingests
  write no row, so install-state stays tied to the curated path.
- manifest: declare the dataset in the medicine-standard tier (runtime fetches
  the remote manifest, so this also needs to land upstream).
- install-gating: the drug-reference home tiles render only when installed.
- uninstall: DrugReferenceService.uninstall() stops the two drug queues, deletes
  the on-disk parts, truncates drug_labels (schema kept), clears the KV markers,
  and drops the install row. Best-effort, logged, scoped to drug data only.
- downloads: the download phase reports the canonical {percent, downloadedBytes,
  totalBytes} shape as one drug-data card in the Active Downloads aggregator with
  cancel/remove; the heavy ingest stays in the IngestStatus surface with an
  Indexing handoff on the card.
- auto-update: a daily DrugAutoUpdateJob compares the manifest export_date and
  re-downloads when newer, gated on installed + no active job.

typecheck clean; the drug standalone suites pass. Three points are flagged in
code for the maintainer: the InstalledResource 'dataset' approach, the tier
home, and the export_date string format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(downloads): keep the drug download card live across all parts

The Active Downloads card filtered to the deterministic jobId, but the
download's continuations run under auto-generated jobIds (only part 0 uses the
deterministic one). So the card tracked part 0 and then vanished while parts
2..N kept downloading. The queue is concurrency 1, so collapse to whichever
single part is in flight and report the deterministic jobId: one card tracks
aggregate progress through the whole download and cancel/remove still routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): co-locate a persistent safety note with affirmative remedy guidance

Add RemedySafetyNote at the head of every natural-remedy section — the two on
Drug Reference and the one on "When to use what" — so the "informational only,
not medical advice, seek real medical care in an emergency" framing appears with
the guidance itself, not only in the page-top banner. Replaces the terse
per-section caveat with the same amber alert language as SafetyBanner.

Addresses the upstream #1040 review request that the disclaimer be unmistakable
and present wherever affirmative self-care guidance appears, not a one-time banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drug-reference): fold dataset freshness into the content-auto-update path

The drug dataset auto-updated on its own daily cron that ignored the
contentAutoUpdate.* master switch, so it would refresh even with content
auto-update turned off, and it didn't ride the content-update path the way
ZIMs and maps do.

Move the export_date freshness/apply orchestration onto
DrugReferenceService.attemptAutoUpdate(), add
ContentAutoUpdateService.attemptDrugDataset() gated on the same enabled +
window config, and have the hourly ContentAutoUpdateJob drive both. Retire
the standalone DrugAutoUpdateJob. The ZIM/map attempt() path is unchanged.

Addresses the upstream #1040 request to wire the openFDA export_date check
into the content updater so the dataset updates alongside ZIMs and maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): strengthen the remedy note to consult a clinician before combining with meds

Widen the affirmative-remedy safety note from "talk to a clinician before use"
to explicitly cover using a remedy AND combining one with a medication the user
already takes — the interaction case is the higher-risk path for an off-grid
user self-treating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): gate affirmative remedy content behind an off-by-default flag

Add drugReference.remediesEnabled (default off), independent of the tier
install. When off, the server emits no remedy data at any boundary — the
drug-reference page prop, conditions show, and the /api/conditions/drugs
situation search — and the "Natural" filter is hidden, so installing the
medicine-standard tier lights up the verbatim FDA label search and the
condition-to-OTC matching but not the hand-authored self-care and herbal
sections. No user-facing toggle: it is flipped on after a clinician content-pass.

Implements the upstream #1040 split-by-risk request: the regulated label content
ships with the tier; the authored remedy guidance stays gated until sign-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
2026-07-21 11:06:18 -07:00

296 lines
11 KiB
TypeScript

import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import { DrugReferenceService } from '#services/drug_reference_service'
import { ConditionService } from '#services/condition_service'
import { searchDrugValidator, interactionsValidator } from '#validators/drug_reference'
import { parseCompareIds } from '../../util/compare_ids.js'
import { situationsForIndications } from '../../util/conditions.js'
import { affirmativeRemediesEnabled } from '../utils/affirmative_remedies.js'
/**
* Drug Reference v1 — HTTP boundary.
*
* Two Inertia pages (index / show) + a small JSON API (search / status /
* download). Mirrors the WorkshopController / InventoryController chain:
* - index/show render Inertia
* - JSON actions return plain objects
* - Integer-id guard on show
* - Never leak exceptions to the UI
*/
export default class DrugReferenceController {
private get service() {
return new DrugReferenceService()
}
/**
* GET /drug-reference — unified search page.
* Passes the current row count and ingest status so the empty-state
* "download first" prompt can render server-side. Also passes the curated
* condition spine so the always-visible situation chips (and the situation→
* drugs direction of the unified surface) can render server-side.
*/
async index({ inertia }: HttpContext) {
try {
const conditionService = new ConditionService()
const [status, count, remediesOn] = await Promise.all([
this.service.getIngestStatus(),
this.service.rowCount(),
affirmativeRemediesEnabled(),
])
return inertia.render('drug-reference/index', {
ingestStatus: status,
rowCount: count,
conditions: conditionService.listConditions(),
// Affirmative remedy content is gated off by default (#1040): keep it out
// of the payload entirely when disabled, and tell the page so it can hide
// the "Natural" filter too. Drug search + condition matching are unaffected.
remedies: remediesOn ? conditionService.listRemedies() : [],
remediesEnabled: remediesOn,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] index failed: ${msg}`)
return inertia.render('drug-reference/index', {
ingestStatus: null,
rowCount: 0,
conditions: [],
remedies: [],
remediesEnabled: false,
})
}
}
/**
* GET /drug-reference/:id — detail page.
*/
async show({ inertia, params, response }: HttpContext) {
const id = Number(params.id)
if (!Number.isInteger(id) || id <= 0) {
return response.notFound({ error: 'invalid id' })
}
try {
const label = await this.service.find(id)
if (!label) {
return response.notFound({ error: 'Drug label not found' })
}
// Reverse link — the other direction of the symbiotic relationship: which
// curated situations does THIS label's indications text treat? Matched
// server-side against the curated spine so searchTerms stay server-only.
const situations = situationsForIndications(
label.indications,
new ConditionService().allConditions()
)
return inertia.render('drug-reference/show', { label, situations })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] show(${id}) failed: ${msg}`)
return response.internalServerError({ error: 'Could not load drug label' })
}
}
/**
* GET /api/drug-reference/search
* Returns a slim collapsed result list (brand+generic pairs).
*/
async search({ request, response }: HttpContext) {
try {
const params = await request.validateUsing(searchDrugValidator)
const results = await this.service.search(params.q, {
productType: params.product_type,
route: params.route,
sort: params.sort,
limit: params.limit,
offset: params.offset,
scope: params.scope,
})
return { results }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceController] search failed: ${msg}`)
return response.badRequest({ error: msg })
}
}
/**
* GET /api/drug-reference/status
* Returns the live ingest status DTO.
*/
async status({ response }: HttpContext) {
try {
const status = await this.service.getIngestStatus()
return status
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] status failed: ${msg}`)
return response.internalServerError({ error: 'Could not read ingest status' })
}
}
/**
* GET /drug-reference/interactions — side-by-side label comparison page.
* Passes rowCount + ingestStatus so the empty-state prompt can render,
* mirroring the index() pattern. The actual entry data is loaded client-side
* via /api/drug-reference/interactions?ids=… so the page is shareable via URL.
*/
async interactions({ inertia }: HttpContext) {
try {
const [status, count] = await Promise.all([
this.service.getIngestStatus(),
this.service.rowCount(),
])
return inertia.render('drug-reference/interactions', {
ingestStatus: status,
rowCount: count,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] interactions page failed: ${msg}`)
return inertia.render('drug-reference/interactions', {
ingestStatus: null,
rowCount: 0,
})
}
}
/**
* GET /api/drug-reference/interactions?ids=1,2,3
* Validates → parses → fetches and returns { entries: DrugInteractionEntry[] }.
* Never leaks exceptions; integer-guards ids via parseCompareIds.
*/
async interactionsApi({ request, response }: HttpContext) {
try {
const params = await request.validateUsing(interactionsValidator)
const ids = parseCompareIds(params.ids ?? '')
const entries = await this.service.getInteractionsFor(ids)
return { entries }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceController] interactionsApi failed: ${msg}`)
return response.badRequest({ error: msg })
}
}
/**
* POST /api/drug-reference/download
* Triggers the download phase (idempotent — deduped on deterministic jobId).
* The download auto-chains the ingest phase on completion.
*/
async download({ response }: HttpContext) {
try {
const result = await this.service.triggerDownload()
return { success: true, created: result.created, message: result.message }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] download trigger failed: ${msg}`)
return response.internalServerError({ error: 'Could not trigger download' })
}
}
/**
* POST /api/drug-reference/ingest
* Manually (re-)runs the ingest phase from the already-downloaded on-disk
* parts, with no re-download. Returns 404 when nothing is on disk so the UI
* can keep its guard honest even if the button is reached out of band.
*/
async ingest({ response }: HttpContext) {
try {
const result = await this.service.triggerIngestFromDisk()
if (result.nothingDownloaded) {
return response.notFound({ error: result.message })
}
return { success: true, created: result.created, message: result.message }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] ingest trigger failed: ${msg}`)
return response.internalServerError({ error: 'Could not trigger ingest' })
}
}
/**
* POST /api/drug-reference/reset-ingest
* Force-clears a wedged ingest job (e.g. one left 'active' by a worker killed
* mid-ingest during an upgrade) and restarts it from the on-disk parts. The
* escape hatch for a stuck "Indexing…" state.
*/
async resetIngest({ response }: HttpContext) {
try {
const result = await this.service.resetAndReingest()
if (result.nothingDownloaded) {
return response.notFound({ error: result.message })
}
return { success: true, created: result.created, message: result.message }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] reset-ingest failed: ${msg}`)
return response.internalServerError({ error: 'Could not reset ingest' })
}
}
/**
* POST /api/drug-reference/uninstall
* Uninstall the offline FDA drug dataset: stop in-flight jobs, delete on-disk
* parts, TRUNCATE drug_labels, clear KV markers, and remove the install-state
* row (which auto-hides the home tiles). The curated-tier "remove" action.
* Reports partial failures rather than masking them.
*/
async uninstall({ response }: HttpContext) {
try {
const result = await this.service.uninstall()
if (!result.success) {
return response.internalServerError({
success: false,
rowsDropped: result.rowsDropped,
error: result.message,
})
}
return { success: true, rowsDropped: result.rowsDropped, message: result.message }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] uninstall failed: ${msg}`)
return response.internalServerError({ error: 'Could not uninstall drug reference' })
}
}
/**
* GET /api/drug-reference/ingest-log
* Tail the persisted app log for ingest/download lines. In production the logger
* writes JSON to /app/storage/logs/admin.log (both the admin and worker
* containers share that volume), so the worker's [IngestDrugDataJob] trace lands
* there even when its stdout never reaches the log viewer. This surfaces it over
* HTTP so the exact stall stage (zip-open vs first-record vs batch) is visible
* without container access. Reads only the last slice of the file.
*/
async ingestLog({ request, response }: HttpContext) {
const LOG_PATH = '/app/storage/logs/admin.log'
const TAIL_BYTES = 128 * 1024
const limit = Math.min(Number(request.input('lines', 400)) || 400, 2000)
try {
const { stat, open } = await import('node:fs/promises')
const st = await stat(LOG_PATH)
const start = Math.max(0, st.size - TAIL_BYTES)
const fh = await open(LOG_PATH, 'r')
try {
const buf = Buffer.alloc(st.size - start)
await fh.read(buf, 0, buf.length, start)
const all = buf.toString('utf8').split('\n')
// Keep only ingest/download/worker-relevant lines; for JSON pino lines the
// substring match still works against the embedded "msg" field.
const re =
/IngestDrugDataJob|DownloadDrugDataJob|DrugReference|drug-ingest|drug-download|unhandledRejection|uncaughtException|queue:work|stalled/i
const matched = all.filter((l) => re.test(l)).slice(-limit)
return { ok: true, path: LOG_PATH, size: st.size, count: matched.length, lines: matched }
} finally {
await fh.close()
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return response.notFound({ ok: false, path: LOG_PATH, error: msg })
}
}
}