This commit is contained in:
caweis 2026-07-18 09:00:17 -07:00 committed by GitHub
commit 4ac7ee0951
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 10919 additions and 27 deletions

View File

@ -0,0 +1,114 @@
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import { ConditionService } from '#services/condition_service'
import { conditionDrugsValidator } from '#validators/conditions'
import { affirmativeRemediesEnabled } from '../utils/affirmative_remedies.js'
/**
* "When to use what" condition-first HTTP boundary (Phase 1).
*
* Two Inertia pages (index / show) + a small JSON API (drugs). Mirrors the
* DrugReferenceController chain:
* - index/show render Inertia
* - the JSON action returns a plain object
* - slug guard on show (404 when not in the curated spine)
* - never leak exceptions to the UI
*/
export default class ConditionsController {
private get service() {
return new ConditionService()
}
/**
* GET /conditions legacy browse route.
* Situation browsing now lives directly on the unified Drug Reference page, so
* the standalone browse route permanently redirects there. Any old bookmark or
* in-app link lands on the same content. The condition detail route
* (/conditions/:slug) is unchanged situation chips still deep-link to it via
* /drug-reference?situation=<slug>.
*/
async index({ response }: HttpContext) {
return response.redirect('/drug-reference')
}
/**
* GET /conditions/:slug condition detail page.
* 404s when the slug is not a curated condition.
*/
async show({ inertia, params, response }: HttpContext) {
const slug = String(params.slug ?? '').trim()
if (!slug) {
return response.notFound({ error: 'invalid condition' })
}
try {
const condition = this.service.findCondition(slug)
if (!condition) {
return response.notFound({ error: 'Condition not found' })
}
const [result, drugRowCount, remediesOn] = await Promise.all([
this.service.drugsForSlug(slug),
this.service.drugRowCount(),
affirmativeRemediesEnabled(),
])
return inertia.render('conditions/show', {
condition: result?.condition ?? null,
drugs: result?.drugs ?? [],
// Affirmative remedies gated off by default (#1040); the OTC/condition
// match above is regulated label text and stays live.
remedies: remediesOn ? (result?.remedies ?? []) : [],
drugRowCount,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[ConditionsController] show(${slug}) failed: ${msg}`)
return response.internalServerError({ error: 'Could not load condition' })
}
}
/**
* GET /api/conditions/drugs?slug= | ?q=
* Returns { condition, drugs } for a curated condition or a free-text
* situation. Requires exactly one of slug/q.
*/
async drugsApi({ request, response }: HttpContext) {
try {
const params = await request.validateUsing(conditionDrugsValidator)
if (params.slug && params.q) {
return response.badRequest({ error: 'Provide either slug or q, not both' })
}
const filterOpts = {
route: params.route,
sort: params.sort,
}
// Strip affirmative remedies from the situation-search response when the
// gate is closed (#1040); the OTC drug matches are regulated label text and
// are returned either way.
const remediesOn = await affirmativeRemediesEnabled()
if (params.slug) {
const result = await this.service.drugsForSlug(params.slug, params.limit, filterOpts)
if (!result) {
return response.notFound({ error: 'Condition not found' })
}
return remediesOn ? result : { ...result, remedies: [] }
}
if (params.q) {
const result = await this.service.drugsForFreeText(params.q, params.limit, filterOpts)
return remediesOn ? result : { ...result, remedies: [] }
}
return response.badRequest({ error: 'Provide a slug or q query parameter' })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[ConditionsController] drugsApi failed: ${msg}`)
return response.badRequest({ error: msg })
}
}
}

View File

@ -0,0 +1,295 @@
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 })
}
}
}

View File

@ -1,4 +1,6 @@
import { SystemService } from '#services/system_service'
import { DrugReferenceService } from '#services/drug_reference_service'
import logger from '@adonisjs/core/services/logger'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@ -18,7 +20,38 @@ export default class HomeController {
return inertia.render('home', {
system: {
services
}
},
// Gate the Drug Reference / "When to use what" tiles behind the FDA
// dataset install state. Installed when the curated-tier ingest has
// reached 'ready' OR an install is in flight (downloading/ingesting) —
// so the tile appears the moment the user opts in and persists through
// the long install, rather than popping in only at the very end.
drugReferenceInstalled: await this.computeDrugReferenceInstalled(),
})
}
/**
* True when the offline FDA drug dataset is installed or installing. Reads the
* two-phase ingest status: ready (fully installed) or an active phase
* (downloading/downloaded/ingesting). rowCount > 0 covers a populated table
* whose job history was pruned. Never throws a status read failure hides the
* tiles (fail-closed) rather than 500-ing the dashboard.
*/
private async computeDrugReferenceInstalled(): Promise<boolean> {
try {
const status = await new DrugReferenceService().getIngestStatus()
const installing =
status.phase === 'downloading' ||
status.phase === 'downloaded' ||
status.phase === 'ingesting'
return status.phase === 'ready' || installing || status.rowCount > 0
} catch (err) {
logger.error(
`[HomeController] drug-reference install check failed: ${
err instanceof Error ? err.message : String(err)
}`
)
return false
}
}
}

View File

@ -0,0 +1,342 @@
/**
* "When to use what" curated condition spine (Phase 1, runtime source of truth).
*
* A bounded, hand-curated list of common first-aid / emergency situations a
* "small booklet" of when-to-use-what. Each entry's `searchTerms` drive the
* FULLTEXT search over `drug_labels.indications`.
*
* WHY a TS constant (not a JSON file read at runtime):
* The repo-root `collections/conditions.json` mirror exists for parity with
* the kiwix-categories convention and for human discoverability, but it is
* NOT what the running app reads. The Dockerfile copies only `admin/` into the
* image (`ADD admin/ ./`) and ships only the compiled `build/` output, so a
* repo-root JSON file never reaches the container filesystem. Bundling the
* spine as a compiled module guarantees it is always present at runtime with
* no path-resolution fragility.
*
* Keep this file and `collections/conditions.json` in sync (same `version`,
* same entries) the JSON is the public/browseable copy, this is the canonical
* runtime copy. `parseConditionsFile` (admin/util/conditions.ts) validates the
* shape so a hand-edit that breaks an entry degrades to "skip that entry"
* rather than crashing the page.
*/
import type { ConditionsFile } from '../../types/conditions.js'
export const CONDITIONS_FILE: ConditionsFile = {
version: '2026-06-07',
conditions: [
// ── Pain, fever & inflammation ──────────────────────────────────────────
{
slug: 'pain',
label: 'Pain',
category: 'Pain, fever & inflammation',
searchTerms: ['pain', 'aches', 'minor aches', 'pain relief', 'analgesic'],
},
{
slug: 'headache',
label: 'Headache',
category: 'Pain, fever & inflammation',
searchTerms: ['headache', 'migraine', 'head pain', 'tension headache'],
},
{
slug: 'muscle-joint-pain',
label: 'Muscle & joint pain',
category: 'Pain, fever & inflammation',
searchTerms: [
'muscular aches',
'muscle aches',
'backache',
'arthritis',
'joint pain',
'minor pain of arthritis',
],
},
{
slug: 'fever',
label: 'Fever',
category: 'Pain, fever & inflammation',
searchTerms: ['fever', 'reduces fever', 'fever reducer', 'temporarily reduces fever'],
},
{
slug: 'menstrual-cramps',
label: 'Menstrual cramps',
category: 'Pain, fever & inflammation',
searchTerms: ['menstrual cramps', 'menstrual pain', 'period pain', 'premenstrual'],
},
// ── Cold, cough & allergy ───────────────────────────────────────────────
{
slug: 'cough',
label: 'Cough',
category: 'Cold, cough & allergy',
searchTerms: ['cough', 'cough suppressant', 'coughing', 'controls cough'],
},
{
slug: 'nasal-congestion',
label: 'Nasal congestion',
category: 'Cold, cough & allergy',
searchTerms: [
'nasal congestion',
'stuffy nose',
'sinus congestion',
'decongestant',
'nasal decongestant',
],
},
{
slug: 'sore-throat',
label: 'Sore throat',
category: 'Cold, cough & allergy',
searchTerms: ['sore throat', 'sore mouth', 'minor sore throat', 'throat pain'],
},
{
slug: 'common-cold',
label: 'Common cold',
category: 'Cold, cough & allergy',
searchTerms: ['common cold', 'cold symptoms', 'cold', 'flu symptoms'],
},
{
slug: 'allergic-reaction',
label: 'Allergies & allergic reactions',
category: 'Cold, cough & allergy',
searchTerms: [
'allergy',
'allergic reactions',
'hay fever',
'antihistamine',
'runny nose',
'sneezing',
'itchy watery eyes',
],
},
// ── Stomach & digestion ─────────────────────────────────────────────────
{
slug: 'heartburn',
label: 'Heartburn',
category: 'Stomach & digestion',
searchTerms: ['heartburn', 'acid indigestion', 'acid reducer', 'antacid', 'sour stomach'],
},
{
slug: 'indigestion',
label: 'Indigestion & upset stomach',
category: 'Stomach & digestion',
searchTerms: ['indigestion', 'upset stomach', 'gas', 'bloating', 'fullness', 'antacid'],
},
{
slug: 'nausea-vomiting',
label: 'Nausea & vomiting',
category: 'Stomach & digestion',
searchTerms: ['nausea', 'vomiting', 'upset stomach associated with nausea'],
},
{
slug: 'diarrhea',
label: 'Diarrhea',
category: 'Stomach & digestion',
searchTerms: ['diarrhea', 'antidiarrheal', 'loose stools', 'travelers diarrhea'],
},
{
slug: 'constipation',
label: 'Constipation',
category: 'Stomach & digestion',
searchTerms: [
'constipation',
'laxative',
'irregularity',
'occasional constipation',
'stool softener',
],
},
{
slug: 'motion-sickness',
label: 'Motion sickness',
category: 'Stomach & digestion',
searchTerms: ['motion sickness', 'travel sickness', 'seasickness', 'car sickness'],
},
{
slug: 'gas',
label: 'Gas & bloating',
category: 'Stomach & digestion',
searchTerms: ['gas', 'bloating', 'flatulence', 'antigas', 'pressure'],
},
// ── Skin & wounds ───────────────────────────────────────────────────────
{
slug: 'wounds-cuts',
label: 'Wounds & cuts',
category: 'Skin & wounds',
searchTerms: [
'minor cuts',
'scrapes',
'wounds',
'first aid antiseptic',
'first aid to help prevent infection',
'abrasions',
],
},
{
slug: 'burns',
label: 'Burns',
category: 'Skin & wounds',
searchTerms: ['burns', 'minor burns', 'sunburn', 'scald', 'minor burn'],
},
{
slug: 'insect-bites-stings',
label: 'Insect bites & stings',
category: 'Skin & wounds',
searchTerms: [
'insect bites',
'insect stings',
'bug bites',
'bee sting',
'itching from insect bites',
],
},
{
slug: 'skin-rash-itch',
label: 'Rash & itching',
category: 'Skin & wounds',
searchTerms: [
'itching',
'rash',
'skin irritation',
'itchy skin',
'minor skin irritations',
'eczema',
],
},
{
slug: 'poison-ivy',
label: 'Poison ivy & plant rashes',
category: 'Skin & wounds',
searchTerms: ['poison ivy', 'poison oak', 'poison sumac', 'rashes due to poison ivy'],
},
{
slug: 'fungal-infection',
label: 'Athletes foot & ringworm',
category: 'Skin & wounds',
searchTerms: [
'athletes foot',
'ringworm',
'jock itch',
'antifungal',
'fungal infection',
'tinea',
],
},
{
slug: 'dry-skin',
label: 'Dry & chapped skin',
category: 'Skin & wounds',
searchTerms: ['dry skin', 'chapped skin', 'chapped lips', 'skin protectant', 'cracked skin'],
},
{
slug: 'acne',
label: 'Acne',
category: 'Skin & wounds',
searchTerms: ['acne', 'pimples', 'blackheads', 'acne treatment'],
},
// ── Eyes, ears & mouth ──────────────────────────────────────────────────
{
slug: 'eye-irritation',
label: 'Eye irritation & dryness',
category: 'Eyes, ears & mouth',
searchTerms: [
'eye irritation',
'dry eyes',
'red eyes',
'eye redness',
'itchy eyes',
'lubricant eye',
],
},
{
slug: 'earache',
label: 'Earache & ear wax',
category: 'Eyes, ears & mouth',
searchTerms: ['earache', 'ear wax', 'earwax removal', 'ear pain', 'swimmers ear'],
},
{
slug: 'canker-sores',
label: 'Canker & cold sores',
category: 'Eyes, ears & mouth',
searchTerms: ['canker sores', 'cold sores', 'mouth sores', 'fever blisters', 'oral pain'],
},
{
slug: 'toothache',
label: 'Toothache',
category: 'Eyes, ears & mouth',
searchTerms: ['toothache', 'tooth pain', 'dental pain', 'oral analgesic'],
},
// ── Sleep, stress & general ─────────────────────────────────────────────
{
slug: 'sleeplessness',
label: 'Sleeplessness',
category: 'Sleep, stress & general',
searchTerms: [
'sleeplessness',
'insomnia',
'sleep aid',
'difficulty falling asleep',
'nighttime',
],
},
{
slug: 'dehydration',
label: 'Dehydration',
category: 'Sleep, stress & general',
searchTerms: [
'dehydration',
'oral rehydration',
'electrolyte',
'fluid loss',
'replaces electrolytes',
],
},
{
slug: 'hemorrhoids',
label: 'Hemorrhoids',
category: 'Sleep, stress & general',
searchTerms: ['hemorrhoids', 'hemorrhoidal', 'anal itching', 'rectal'],
},
// ── Infections (OTC-treatable) ──────────────────────────────────────────
{
slug: 'yeast-infection',
label: 'Vaginal yeast infection',
category: 'Infections (OTC-treatable)',
searchTerms: [
'vaginal yeast infection',
'yeast infection',
'vaginal antifungal',
'candidiasis',
],
},
{
slug: 'pinworm',
label: 'Pinworm',
category: 'Infections (OTC-treatable)',
searchTerms: ['pinworm', 'pinworm infection', 'pinworm treatment'],
},
{
slug: 'cold-sore-lip',
label: 'Chapped & sun-protected lips',
category: 'Sleep, stress & general',
searchTerms: ['lip protectant', 'chapped lips', 'sunburn protection lips', 'lip balm'],
},
{
slug: 'eye-allergy',
label: 'Eye allergies',
category: 'Eyes, ears & mouth',
searchTerms: [
'eye allergy',
'itchy eyes due to allergies',
'ocular itching',
'allergic conjunctivitis',
],
},
],
}

View File

@ -0,0 +1,329 @@
/**
* Non-herbal home-care / self-care measures (Phase 2b, runtime source of truth).
*
* Same pattern as natural_remedies.ts / conditions.ts: the repo-root
* collections/home_remedies.json is the browseable mirror, this compiled module
* is what the running app reads (the image ships only compiled admin/build/).
* Keep the two in sync. Sources are US-government public-domain pages (CDC,
* NIH/NLM MedlinePlus summaries, FDA consumer updates) each entry carries its
* exact sourceUrl.
*/
import type { NaturalRemediesFile } from '../../types/conditions.js'
export const HOME_REMEDIES_FILE: NaturalRemediesFile = {
"version": "2026-06-10",
"source": {
"name": "US government health guidance (CDC, NIH/NHLBI, MedlinePlus/NLM, FDA)",
"url": "https://www.cdc.gov",
"license": "Public domain (US government works)"
},
"remedies": [
{
"slug": "honey-for-cough",
"name": "Honey (for cough)",
"commonNames": [],
"conditions": [
"cough",
"sore-throat",
"common-cold"
],
"uses": "Honey may be used to relieve cough in adults and children at least 1 year old. One to two teaspoons can be taken directly or stirred into a warm (not hot) beverage.",
"how": "Give one to two teaspoons of honey directly by mouth, or stir it into a warm (not hot) drink. The CDC recommends this as a home measure for cough associated with the common cold.",
"evidence": "CDC lists honey among recommended home measures for easing cough and sore throat associated with the common cold; it is one of several non-medication strategies mentioned alongside rest, fluids, and humidifier use.",
"cautions": "Never give honey to infants under 1 year old — it can contain Clostridium botulinum spores that cause infant botulism, a rare but serious illness. This warning applies regardless of honey type or brand.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "fluids-and-rest",
"name": "Fluids and rest",
"commonNames": [],
"conditions": [
"common-cold",
"fever",
"diarrhea",
"nausea-vomiting"
],
"uses": "Getting plenty of rest and drinking adequate fluids (water, clear broths, juice, or sports drinks) supports recovery from colds, fever, diarrhea, and nausea. Adults with diarrhea should drink water, fruit juices, sports drinks, sodas without caffeine, and salty broths.",
"how": "Get plenty of rest and drink plenty of fluids. If keeping liquids down is difficult, take small sips of water or suck on ice chips frequently rather than trying to drink large amounts at once.",
"evidence": "CDC recommends rest and fluids as primary home care measures for the common cold. MedlinePlus (NIH/NIDDK) similarly lists these as the foundation of diarrhea self-care, and advises nausea patients to take in small amounts of clear liquids often to stay hydrated.",
"cautions": "Severely ill individuals, those with signs of dehydration (no urination, sunken eyes, extreme thirst), or those unable to keep any fluid down should seek medical care promptly. Caffeine and alcohol are not effective rehydration choices.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "oral-rehydration",
"name": "Oral rehydration solution (ORS)",
"commonNames": [
"ORS",
"rehydration salts"
],
"conditions": [
"dehydration",
"diarrhea"
],
"uses": "Oral rehydration solutions replace fluids and electrolytes lost through diarrhea or other causes of dehydration. For mild to moderate dehydration, drinking water is the first step; sports drinks or oral rehydration solutions (such as Pedialyte) are recommended when electrolytes are also depleted, especially for children.",
"how": "Use a commercially prepared oral rehydration solution (available without a prescription) and follow the package directions. For adults with electrolyte losses, sports drinks can help; if liquids are hard to keep down, take small sips frequently or suck on ice chips rather than drinking large amounts at once.",
"evidence": "MedlinePlus (NIH/NIDDK) states that treatment for dehydration involves replacing lost fluids and electrolytes, and that oral rehydration solutions for children are available without a prescription. The same source recommends sports drinks for adults when electrolytes have been lost alongside fluids.",
"cautions": "Seek immediate medical care for signs of severe dehydration: no urination for 8 or more hours, rapid heartbeat, confusion, or inability to keep fluids down. Infants and small children with diarrhea should use formulated ORS (not plain water) to replace electrolytes safely.",
"sourceUrl": "https://medlineplus.gov/dehydration.html"
},
{
"slug": "cool-compress-fever",
"name": "Cool compress (for fever and insect bites)",
"commonNames": [],
"conditions": [
"fever",
"insect-bites-stings",
"eye-allergy",
"eye-irritation"
],
"uses": "A clean cloth soaked in cool (not ice-cold) water and placed on the forehead or bitten area can help reduce discomfort from fever, insect bites, and eye allergy symptoms. For insect stings, ice wrapped in a washcloth should be applied for 10 minutes on and 10 minutes off.",
"how": "Soak a clean cloth in cool water, wring it out, and place it on the forehead or affected area. For insect stings, wrap ice in a cloth and apply for 10 minutes on, then 10 minutes off — never place ice directly on bare skin.",
"evidence": "MedlinePlus (NIH/NIAID) notes that applying cool compresses is recommended for allergic conjunctivitis and eye burning and irritation. For insect bites and stings, a cool or iced compress is a standard first-line self-care step described in MedlinePlus search guidance consistent with NIH resources.",
"cautions": "Do not apply ice or an ice-cold compress directly to bare skin for extended periods — wrap ice in cloth and limit applications to 1015 minutes to avoid frostbite or tissue damage. For fever, cool compresses supplement (but do not replace) appropriate fever-reducing medicine when indicated; consult a healthcare provider if fever is high, prolonged, or accompanied by severe symptoms.",
"sourceUrl": "https://medlineplus.gov/insectbitesandstings.html"
},
{
"slug": "ice-and-elevation",
"name": "Ice and elevation (RICE method)",
"commonNames": [
"RICE",
"Rest-Ice-Compression-Elevation"
],
"conditions": [
"muscle-joint-pain",
"insect-bites-stings"
],
"uses": "Applying ice wrapped in cloth to a strained muscle, sprain, or bite site — combined with rest, compression, and elevation of the injured area — reduces swelling and pain. Ice should be applied for 1015 minutes every 13 hours during the first few days of injury.",
"how": "Wrap ice in a cloth or towel and apply to the injured area for 1015 minutes at a time. Rest the area, wrap it snugly with a bandage to reduce swelling, and elevate it above heart level when possible.",
"evidence": "MedlinePlus (NIH/NIAMS) describes the RICE method as standard first-line treatment for sprains and strains: resting the area, icing it, compressing it with a bandage, and elevating it above heart level when possible. Ice use for the first 3 days is specifically mentioned.",
"cautions": "Never apply ice directly to skin — always wrap it in a cloth or towel. If swelling worsens significantly, numbness develops, or you suspect a fracture, seek medical evaluation. Do not use heat during the first 4872 hours of an acute soft-tissue injury.",
"sourceUrl": "https://medlineplus.gov/sprainsandstrains.html"
},
{
"slug": "heating-pad",
"name": "Heating pad or warm compress",
"commonNames": [],
"conditions": [
"menstrual-cramps",
"muscle-joint-pain",
"earache"
],
"uses": "Applying a heating pad or hot water bottle to the lower abdomen eases menstrual cramps. A warm compress applied to the ear can relieve earache discomfort. Heat may also be used on strained muscles after the first 4872 hours of an acute injury.",
"how": "Place a heating pad or hot water bottle on the lower abdomen for menstrual cramps, or hold a warm cloth against the affected ear for earache. Use a low or medium heat setting and put a cloth between the pad and skin to prevent burns.",
"evidence": "MedlinePlus (NIH/NLM) lists using a heating pad or hot water bottle on the lower abdomen, along with taking a warm bath, as home care measures for period pain. For earache, placing a warm cloth on the affected ear is listed among comfort measures in NIH/NLM resources for acute ear infection self-care.",
"cautions": "Never fall asleep with a heating pad on — burns can result. Use a low or medium setting and place a cloth between the pad and skin. For ear pain, do not insert anything into the ear canal; if pain is severe, accompanied by drainage, hearing loss, or fever, see a healthcare provider to rule out infection requiring antibiotics.",
"sourceUrl": "https://medlineplus.gov/periodpain.html"
},
{
"slug": "humidifier-and-steam",
"name": "Humidifier or steam inhalation",
"commonNames": [
"cool-mist vaporizer",
"steam inhalation"
],
"conditions": [
"nasal-congestion",
"cough",
"common-cold"
],
"uses": "Using a clean humidifier or cool-mist vaporizer adds moisture to the air and can help relieve nasal congestion and cough. Breathing steam from a bowl of hot water or a running shower 24 times daily also loosens nasal secretions.",
"how": "Fill a clean cool-mist humidifier or vaporizer with water and run it in the room. Clean the device daily per the manufacturer's instructions to prevent mold and bacteria buildup.",
"evidence": "CDC recommends using a clean humidifier or cool-mist vaporizer as a home care measure for common cold symptoms including congestion and cough. MedlinePlus similarly notes that a humidifier can break up mucus and that steam inhalation from a shower is a recognized congestion-relief strategy.",
"cautions": "Clean the humidifier daily per manufacturer instructions to prevent mold and bacterial growth. Use cool-mist humidifiers rather than warm-mist (steam) versions for children to avoid burn risk. When inhaling steam from hot water, use caution to avoid scalding; keep a safe distance and do not cover your head over a pot of boiling water.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "saline-nasal-rinse",
"name": "Saline nasal rinse",
"commonNames": [
"neti pot",
"nasal irrigation",
"saline nasal wash"
],
"conditions": [
"nasal-congestion",
"common-cold"
],
"uses": "Saline nasal rinses flush pollen, dust, and excess mucus from the nasal passages and add moisture. They can be performed with a neti pot, squeeze bottle, or bulb syringe using a prepared saline solution.",
"how": "Use only distilled, sterile, or previously boiled-and-cooled water — never tap water. After each use, rinse the device with the same safe water, then air-dry it thoroughly or wipe dry before storing.",
"evidence": "CDC recommends saline nasal spray or drops as a home care measure for the common cold. The FDA confirms that nasal irrigation devices are 'usually safe and effective products when used and cleaned properly,' with the critical safety requirement being the type of water used.",
"cautions": "Use only distilled, sterile, or previously boiled (and cooled) water — never tap water. The FDA warns that tap water can harbor organisms including amoebas that are safe to swallow but can cause serious or potentially fatal infections in the nasal passages. Boiled water should be cooled to lukewarm and stored in a clean, closed container for no more than 24 hours. Always clean and dry the device after each use.",
"sourceUrl": "https://www.fda.gov/consumers/consumer-updates/rinsing-your-sinuses-neti-pots-safe"
},
{
"slug": "oatmeal-bath",
"name": "Oatmeal or cool bath",
"commonNames": [
"colloidal oatmeal bath"
],
"conditions": [
"skin-rash-itch",
"poison-ivy",
"dry-skin"
],
"uses": "Soaking in a lukewarm oatmeal bath or taking a cool bath can relieve itching and skin irritation from rashes, poison ivy, eczema, and dry skin. Colloidal oatmeal bath products are available at drugstores.",
"how": "Fill a tub with lukewarm (not hot) water and add a colloidal oatmeal bath product according to package directions, or use plain cool water. After soaking, pat skin dry gently and apply a fragrance-free moisturizer immediately to lock in moisture.",
"evidence": "MedlinePlus (NLM) recommends taking 'lukewarm or oatmeal baths' as a self-care measure for itching, alongside cool compresses and moisturizing lotion. Oatmeal bath products are specifically noted to relieve symptoms of eczema and psoriasis; short, cooler baths are described as better than long, hot baths for skin conditions.",
"cautions": "Use lukewarm — not hot — water; hot water can worsen skin dryness and irritation. After bathing, pat skin dry gently (do not rub) and immediately apply a fragrance-free moisturizer to lock in moisture. If a rash is spreading rapidly, is accompanied by fever, or involves the face or genitals, consult a healthcare provider.",
"sourceUrl": "https://medlineplus.gov/itching.html"
},
{
"slug": "cool-running-water-on-burns",
"name": "Cool running water on burns",
"commonNames": [],
"conditions": [
"burns"
],
"uses": "For minor burns, immediately run cool (not cold) water slowly over the burned area for 1015 minutes to stop the burning process and reduce pain. After cooling, cover the burn with a clean, dry cloth or sterile bandage.",
"how": "Run cool water slowly over the burned area for several minutes, then cover with a clean, dry cloth or bandage. Do not apply ice, butter, or any creams — these can worsen tissue damage.",
"evidence": "CDC burn first-aid materials instruct: run cool water slowly over the burn area for several minutes, then cover with a clean, dry cloth or bandage. MedlinePlus (NIH/NIGMS) similarly specifies cool running water for 1015 minutes followed by a dry sterile dressing as the appropriate first-aid response for minor burns.",
"cautions": "Do not apply ice, ice water, butter, first-aid creams, sprays, or home remedies — these can worsen tissue damage or introduce infection. Do not break blisters unless directed by a healthcare provider. Do not try to remove clothing or debris stuck to the burn. Seek immediate medical care for large burns, burns on the face, eyes, hands, or feet, burns from chemicals or electricity, or any burn with extreme pain, numbness, or deep tissue involvement.",
"sourceUrl": "https://medlineplus.gov/burns.html"
},
{
"slug": "clean-and-cover-wounds",
"name": "Clean and cover wounds and cuts",
"commonNames": [],
"conditions": [
"wounds-cuts"
],
"uses": "For minor cuts and scrapes, rinse the wound thoroughly with cool clean water to remove dirt, apply gentle pressure with gauze to stop bleeding, then cover with a clean bandage. Wash with soap and water to reduce infection risk.",
"how": "Apply firm but gentle pressure with gauze to stop bleeding; if blood soaks through, add more gauze on top without removing the first layer. Rinse the wound with cool clean water, then cover it with a clean dry bandage.",
"evidence": "CDC guidance on wound care states: put pressure on a bleeding cut until it stops, gently pour clean water over the wound to clean it, then apply a clean, dry bandage. MedlinePlus (NLM) similarly advises rinsing cuts with cool water and applying firm but gentle pressure to stop bleeding.",
"cautions": "Watch for signs of infection in the days following — increasing redness, swelling, warmth, pus, or red streaks spreading from the wound require prompt medical evaluation. Seek immediate care for wounds that are deep, gaping, caused by an animal or human bite, or associated with a puncture from a potentially contaminated object (tetanus risk).",
"sourceUrl": "https://medlineplus.gov/firstaid.html"
},
{
"slug": "salt-water-gargle",
"name": "Salt-water gargle",
"commonNames": [],
"conditions": [
"sore-throat",
"common-cold",
"canker-sores"
],
"uses": "Gargling with warm salt water several times a day can ease sore throat pain and may also help relieve canker sore discomfort. A standard preparation is ½ teaspoon (3 grams) of salt dissolved in 1 cup (240 mL) of warm water.",
"how": "Dissolve salt in a cup of warm water, take a mouthful, tilt your head back, and gargle for several seconds before spitting it out. Repeat as needed throughout the day to help ease sore throat pain.",
"evidence": "MedlinePlus (NLM) states that gargling may ease sore throat pain, listing it alongside lozenges and fluids. The same resource notes that salt-water rinses may help with canker sore discomfort, though mouthwashes containing alcohol should be avoided as they irritate the tissue.",
"cautions": "Gargling with salt water provides symptomatic relief only and does not treat the underlying cause of a sore throat. Sore throat accompanied by high fever, difficulty swallowing or breathing, drooling, a stiff neck, or lasting more than a week should be evaluated by a healthcare provider, as strep throat and other conditions require different treatment.",
"sourceUrl": "https://medlineplus.gov/sorethroat.html"
},
{
"slug": "fiber-and-water-constipation",
"name": "Dietary fiber and water (for constipation and hemorrhoids)",
"commonNames": [],
"conditions": [
"constipation",
"hemorrhoids"
],
"uses": "Eating more fruits, vegetables, and whole grains (which are high in fiber) and drinking plenty of water each day are the foundational self-care steps for preventing and relieving constipation and reducing hemorrhoid discomfort.",
"how": "Eat more fruits, vegetables, and whole grains each day and drink plenty of fluids. Increase fiber gradually to avoid gas and bloating.",
"evidence": "MedlinePlus (NIH/NIDDK) lists increased dietary fiber and adequate fluid intake as primary self-care measures for both constipation and hemorrhoids. The hemorrhoids topic page specifically recommends eating high-fiber foods and drinking enough fluids every day as the first-line home treatment.",
"cautions": "Increase dietary fiber gradually to avoid gas and bloating. If constipation is new, severe, accompanied by blood in the stool, or associated with significant weight loss, see a healthcare provider to rule out underlying conditions. Hemorrhoid symptoms persisting beyond one week of home treatment, or any rectal bleeding, warrant medical evaluation.",
"sourceUrl": "https://medlineplus.gov/constipation.html"
},
{
"slug": "sitz-bath",
"name": "Sitz bath (for hemorrhoids)",
"commonNames": [],
"conditions": [
"hemorrhoids"
],
"uses": "A sitz bath — sitting in a few inches of warm water for 1015 minutes, several times a day — relieves the pain and itching of hemorrhoids. A special sitz bath tub that fits over a toilet is available at pharmacies.",
"how": "Fill a tub or sitz bath basin with a few inches of comfortably warm water and sit in it for 10 to 15 minutes. Repeat several times a day, keeping the area clean and dry between baths.",
"evidence": "MedlinePlus (NLM) lists taking warm baths several times a day, including sitz baths, as a recommended home care measure to relieve hemorrhoid pain. The recommendation is to sit in warm water for 10 to 15 minutes per session.",
"cautions": "The water should be comfortably warm — not hot — to avoid burns. Keep the area clean and dry between baths. If hemorrhoid symptoms do not improve after one week of home treatment, or if there is rectal bleeding, see a healthcare provider.",
"sourceUrl": "https://medlineplus.gov/hemorrhoids.html"
},
{
"slug": "elevate-head-heartburn",
"name": "Elevate head of bed (for heartburn)",
"commonNames": [],
"conditions": [
"heartburn",
"indigestion"
],
"uses": "Raising the head of the bed 46 inches (using blocks under the bed frame or a wedge support) prevents stomach acid from backing up into the esophagus during sleep, reducing nighttime heartburn and GERD symptoms.",
"how": "Place blocks under the legs at the head of the bed frame, or use a foam wedge under the mattress, to raise the sleeping surface about 6 inches. Using extra pillows under only the head is less effective because it bends the body at the waist rather than tilting the whole torso.",
"evidence": "MedlinePlus heartburn resources (sourced from NIH/NIDDK) consistently recommend elevating the head during sleep as a lifestyle measure for heartburn and GERD, noting that this position helps prevent reflux. Sleeping with the head raised about 6 inches is a specific recommendation described in the Medical Encyclopedia entry.",
"cautions": "Using extra pillows under the head is less effective than raising the entire upper body — pillows can cause neck strain and do not sufficiently change the angle. Heartburn that is frequent, severe, or accompanied by difficulty swallowing, unexplained weight loss, or vomiting blood requires medical evaluation.",
"sourceUrl": "https://medlineplus.gov/heartburn.html"
},
{
"slug": "bland-small-meals",
"name": "Small, frequent bland meals",
"commonNames": [
"BRAT diet",
"bland diet"
],
"conditions": [
"nausea-vomiting",
"indigestion",
"diarrhea"
],
"uses": "Eating 68 small bland meals throughout the day (crackers, toast, baked chicken, rice, potatoes) instead of 3 large meals reduces nausea and eases indigestion. As diarrhea symptoms improve, soft bland foods can be introduced gradually.",
"how": "Eat smaller meals more often and stick to bland foods, avoiding spicy, fatty, or salty options. If you have trouble keeping food down, start with small sips of clear liquids frequently and add bland solids only when tolerated.",
"evidence": "MedlinePlus (NLM) recommends small, frequent bland meals and avoiding spicy, fatty, or salty foods as the primary dietary self-care for nausea and vomiting. For indigestion, MedlinePlus notes that avoiding foods and situations that trigger symptoms is the main home strategy. For diarrhea, 'soft, bland food' is recommended as symptoms improve.",
"cautions": "The BRAT diet (bananas, rice, applesauce, toast) was historically promoted but MedlinePlus notes there is not strong evidence it is better than a standard bland diet; it probably does not cause harm. If nausea or vomiting persists beyond 2448 hours, is accompanied by severe pain, or prevents adequate fluid intake, seek medical care.",
"sourceUrl": "https://medlineplus.gov/nauseaandvomiting.html"
},
{
"slug": "dark-quiet-room-headache",
"name": "Rest in a dark, quiet room (for headache)",
"commonNames": [],
"conditions": [
"headache",
"sleeplessness"
],
"uses": "Resting with eyes closed in a dark, quiet room is a recommended non-medication self-care measure during a headache or migraine. Drinking water to prevent dehydration and placing a cool cloth on the forehead are often combined with this rest.",
"how": "Go to a quiet, darkened room, close your eyes, and rest. Drink water and place a cool damp cloth on your forehead to help ease discomfort.",
"evidence": "MedlinePlus (NLM) describes resting in a quiet, darkened room as one of the key things you can do to feel better during a headache or migraine, alongside drinking water and using relaxation techniques. These recommendations are attributed to NIH sources on headache and migraine management.",
"cautions": "Sudden severe ('thunderclap') headache, headache with fever, stiff neck, confusion, or vision changes may signal a serious condition and require emergency evaluation. Frequent headaches that disrupt daily life should be discussed with a healthcare provider rather than managed solely at home.",
"sourceUrl": "https://medlineplus.gov/headache.html"
},
{
"slug": "sleep-hygiene",
"name": "Sleep hygiene practices",
"commonNames": [
"good sleep habits"
],
"conditions": [
"sleeplessness"
],
"uses": "A consistent set of behavioral practices — consistent bedtime and wake time, a cool and dark bedroom, avoiding screens and caffeine near bedtime, and regular exercise — helps adults achieve and maintain adequate sleep.",
"how": "Go to bed and wake up at the same time every day. Keep the bedroom quiet, dark, and cool; turn off electronic devices at least 30 minutes before bedtime; and avoid caffeine in the afternoon and evening and large meals or alcohol before bed.",
"evidence": "CDC and NHLBI both recommend these specific practices for healthy sleep: going to bed and waking at the same time daily, keeping the bedroom quiet, cool, and dark, turning off screens at least 30 minutes before bed, avoiding caffeine in the afternoon and evening, and avoiding large meals or alcohol before sleep. NHLBI notes these habits are particularly important for shift workers and people with insomnia.",
"cautions": "Good sleep hygiene can help relieve short-term insomnia; persistent insomnia lasting more than a few weeks should be evaluated by a healthcare provider. Sleep difficulties accompanied by snoring, gasping during sleep, or excessive daytime sleepiness may indicate obstructive sleep apnea, which requires medical diagnosis.",
"sourceUrl": "https://www.cdc.gov/sleep/about/index.html"
},
{
"slug": "pinworm-hygiene",
"name": "Hygiene measures for pinworm",
"commonNames": [],
"conditions": [
"pinworm"
],
"uses": "Thorough handwashing with soap and warm water (especially after toilet use and before eating), daily morning bathing with soap and water, daily underwear changes, short and clean fingernails, and laundering of bedding and pajamas in hot water are the key household self-care measures for managing pinworm infection.",
"how": "Bathe after waking up each morning and wash hands regularly, especially after using the bathroom. Change underwear daily, wash pajamas and bed sheets often, and avoid nail biting to prevent reinfection.",
"evidence": "CDC identifies handwashing as 'the most important way to prevent the spread of pinworms.' MedlinePlus (NIH/NIAID) lists bathe after waking up, wash pajamas and bed sheets often, wash hands regularly, change underwear every day, and avoid nail biting and scratching the anal area as the core preventive hygiene steps.",
"cautions": "Hygiene alone typically cannot eliminate an active pinworm infection — over-the-counter antiparasitic medication (pyrantel pamoate) is generally needed, and all household members should be treated simultaneously. Medication is typically repeated after 2 weeks because it kills worms but not eggs; the second dose treats worms that hatched after the first dose.",
"sourceUrl": "https://medlineplus.gov/pinworms.html"
},
{
"slug": "keep-feet-dry-athlete-foot",
"name": "Keep feet clean and dry (for fungal infection)",
"commonNames": [
"athlete's foot self-care"
],
"conditions": [
"fungal-infection"
],
"uses": "Keeping the feet clean, dry, and cool — including washing daily with soap and water, drying carefully between the toes, wearing clean cotton socks, and not walking barefoot in public showers or locker rooms — supports treatment and prevents spread of athlete's foot and other tinea infections.",
"how": "Keep feet clean, dry, and cool; wear clean socks and avoid walking barefoot in public areas such as locker room showers (use flip-flops instead). Apply an over-the-counter antifungal cream as directed on the package for most cases of athlete's foot.",
"evidence": "MedlinePlus (CDC-sourced) advises: keep your feet clean, dry, and cool; wear clean socks; avoid walking barefoot in public areas; wear flip-flops in locker room showers; keep toenails clean and clipped short. Over-the-counter antifungal creams work for most cases of athlete's foot.",
"cautions": "If over-the-counter antifungal treatment does not improve the infection within 24 weeks, see a healthcare provider. Spreading or worsening redness, warmth, and swelling — especially in people with diabetes or circulatory problems — warrants prompt medical care. Nail fungal infections are more difficult to treat than skin infections and often require prescription therapy.",
"sourceUrl": "https://medlineplus.gov/athletesfoot.html"
}
]
}

View File

@ -0,0 +1,329 @@
/**
* "When to use what" curated natural-remedies data (Phase 2, runtime source of truth).
*
* Hand-curated subset of NCCIH "Herbs at a Glance" (nccih.nih.gov) mapped to our
* 36-condition spine. Source is US government / public domain; reproduction
* encouraged with credit (credit appears in the NaturalRemediesFile.source field
* and in the UI section caveat).
*
* WHY a TS constant (not a JSON file read at runtime):
* The repo-root `collections/natural_remedies.json` mirror exists for human
* discoverability and parity with the kiwix-categories / conditions convention,
* but it is NOT what the running app reads. The Dockerfile copies only `admin/`
* into the image (`ADD admin/ ./`) and ships only the compiled `build/` output,
* so a repo-root JSON file never reaches the container filesystem. Bundling the
* remedies as a compiled module guarantees they are always present at runtime
* with no path-resolution fragility.
*
* Keep this file and `collections/natural_remedies.json` in sync same `version`,
* same remedy slugs, same remedy count. `parseNaturalRemediesFile` (admin/util/conditions.ts)
* validates the shape so a hand-edit that breaks an entry degrades to "skip that
* entry" rather than crashing the page. The standalone test asserts sync.
*/
import type { NaturalRemediesFile } from '../../types/conditions.js'
export const NATURAL_REMEDIES_FILE: NaturalRemediesFile = {
"version": "2026-06-10",
"source": {
"name": "NCCIH — Herbs at a Glance",
"url": "https://www.nccih.nih.gov/health/herbsataglance",
"license": "Public domain (US government work; NCCIH)"
},
"remedies": [
{
"slug": "aloe-vera",
"name": "Aloe Vera",
"commonNames": [
"Aloe barbadensis miller"
],
"conditions": [
"burns",
"acne",
"dry-skin"
],
"uses": "Aloe vera gel is applied topically for burns, acne, and various skin conditions including psoriasis and radiation-related skin damage. Oral use is promoted for digestive conditions, though topical applications have more research support.",
"how": "Apply gel from the inner leaf of the aloe plant (or a commercially prepared aloe gel) directly to the affected skin area. Topical use is the application with the most research support; oral aloe products carry more serious risks and should not be used without consulting a healthcare provider.",
"evidence": "Research suggests topical aloe gel may speed burn healing and reduce burn-related pain. Two small studies indicate aloe gel, combined with other treatments, may improve acne; evidence for other skin uses is limited.",
"cautions": "Topical use is generally well tolerated, though occasional burning or itching may occur. Oral use carries more serious risks: the latex can cause abdominal pain and diarrhea, oral extracts have been linked to cases of acute hepatitis, and animal studies associated non-decolorized extracts with gastrointestinal cancer. Likely unsafe during pregnancy; may interact with medications such as digoxin.",
"sourceUrl": "https://www.nccih.nih.gov/health/aloe-vera"
},
{
"slug": "boswellia",
"name": "Boswellia",
"commonNames": [
"Boswellia serrata",
"Indian frankincense"
],
"conditions": [
"muscle-joint-pain"
],
"uses": "Boswellia resin extract is traditionally used to reduce inflammation and pain, and is promoted as a dietary supplement to support joint health and mobility, particularly for osteoarthritis.",
"how": "Boswellia is taken orally as a dietary supplement. Clinical trials have used doses up to 1,000 mg daily for up to 6 months; follow the product label and consult a healthcare provider before starting.",
"evidence": "Some studies suggest oral boswellia may help reduce inflammation and pain associated with osteoarthritis, but larger rigorous trials are needed; topical use lacks sufficient evidence of effectiveness.",
"cautions": "Extracts up to 1,000 mg daily have been used safely in clinical trials lasting up to 6 months. Consult a healthcare provider before use, especially if pregnant, breastfeeding, managing asthma, or taking medications, as interactions remain unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/boswellia"
},
{
"slug": "bromelain",
"name": "Bromelain",
"commonNames": [
"pineapple enzyme",
"Ananas comosus extract"
],
"conditions": [
"muscle-joint-pain",
"nasal-congestion"
],
"uses": "Bromelain, an enzyme from pineapple, is promoted for reducing postoperative pain and swelling, sinusitis, osteoarthritis, and exercise-induced muscle soreness. A topical formulation has FDA approval for debridement of severe burns.",
"evidence": "Some studies suggest oral bromelain may reduce certain symptoms after wisdom tooth surgery; evidence for sinusitis is insufficient. The topical formulation has been successfully used by health professionals for burn debridement as an alternative to surgical debridement.",
"cautions": "Oral bromelain is generally well tolerated; the most common side effects are stomach upset and diarrhea. Talk with a healthcare provider before use alongside any medications, as harmful interactions are possible. Safety during pregnancy and breastfeeding is unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/bromelain"
},
{
"slug": "butterbur",
"name": "Butterbur",
"commonNames": [
"Petasites hybridus"
],
"conditions": [
"headache",
"allergic-reaction"
],
"uses": "Butterbur root extract is used for migraine prevention and for reducing symptoms of allergic rhinitis (hay fever). It has been studied for reducing migraine frequency in both adults and children.",
"how": "Butterbur is taken orally as a root extract for migraine prevention or as a leaf extract for allergic rhinitis symptoms. Use only products that are certified and labeled as free of pyrrolizidine alkaloids (PA-free), as the untreated plant contains compounds that can damage the liver.",
"evidence": "Studies of a butterbur root extract suggest it may reduce migraine frequency; a leaf extract may help with allergic rhinitis symptoms. However, the American Academy of Neurology withdrew its 2012 recommendation in 2015 due to safety concerns.",
"cautions": "The plant naturally contains pyrrolizidine alkaloids (PAs) that can damage the liver and lungs and may cause cancer; only PA-free certified products should be used. Even PA-free products have been linked to rare cases of liver injury. Side effects include belching, diarrhea, drowsiness, rash, and stomach upset. Avoid during pregnancy; people allergic to ragweed or related plants are at higher risk of reactions.",
"sourceUrl": "https://www.nccih.nih.gov/health/butterbur"
},
{
"slug": "chamomile",
"name": "Chamomile",
"commonNames": [
"Matricaria chamomilla",
"German chamomile",
"Chamaemelum nobile"
],
"conditions": [
"sleeplessness",
"indigestion",
"common-cold",
"sore-throat",
"skin-rash-itch"
],
"uses": "Chamomile is promoted for insomnia, indigestion, anxiety, the common cold, and infant colic; it is also used topically for skin conditions and as a mouthwash for oral inflammation.",
"how": "Chamomile is commonly consumed as a tea (considered safe in amounts typically found in teas), taken as an oral supplement, applied topically to skin, or used as a mouthwash for oral inflammation. Consult a healthcare provider before using oral supplements rather than tea.",
"evidence": "Evidence is limited: some preliminary studies suggest chamomile supplements may help with anxiety, and combination products may help childhood diarrhea and infant colic. A 2019 review found minimal evidence for insomnia, with one study showing no benefit. Evidence for cold, sore throat, and skin uses in people is insufficient.",
"cautions": "Generally considered safe in typical amounts. Side effects may include nausea, dizziness, and allergic reactions, including severe hypersensitivity; risk is higher for those sensitive to ragweed, chrysanthemums, marigolds, or daisies. May interact with blood thinners, birth control pills, sedatives, and liver-metabolized drugs. Safety during pregnancy and breastfeeding is unknown.",
"sourceUrl": "https://www.nccih.nih.gov/health/chamomile"
},
{
"slug": "echinacea",
"name": "Echinacea",
"commonNames": [
"Echinacea purpurea",
"purple coneflower"
],
"conditions": [
"common-cold"
],
"uses": "Echinacea is primarily marketed for the common cold and upper respiratory tract infections, based on the idea that it may support immune system function.",
"evidence": "Studies indicate that taking echinacea may slightly reduce the chances of catching a cold, though evidence regarding whether it shortens cold duration is inconclusive. Evidence for other conditions, including eczema, is unclear.",
"cautions": "E. purpurea extracts appear likely safe for short periods in adults; allergic reactions can occur, with digestive symptoms most common. Children may experience rashes potentially linked to allergic reactions. Theoretical interactions with immunosuppressants and certain other drugs; consult a healthcare provider before use with medications. Limited data on safety in early pregnancy.",
"sourceUrl": "https://www.nccih.nih.gov/health/echinacea"
},
{
"slug": "elderberry",
"name": "Elderberry",
"commonNames": [
"Sambucus nigra"
],
"conditions": [
"common-cold"
],
"uses": "Elderberry has been used in folk medicine to treat colds and flu, and is promoted as a dietary supplement for colds, flu, and other upper respiratory infections.",
"how": "Elderberry is used as a prepared dietary supplement (syrup, capsule, or lozenge) rather than raw fruit. Never eat raw or unripe elderberries — they contain cyanide-producing substances that cause nausea, vomiting, and severe diarrhea; cooking the berries eliminates this toxin.",
"evidence": "A small number of studies suggest elderberry may relieve symptoms of flu, colds, or other upper respiratory infections; however, the overall evidence is limited and insufficient for most other claimed benefits.",
"cautions": "Raw or unripe elderberries contain cyanide-producing substances that can cause nausea, vomiting, and severe diarrhea; cooking eliminates this toxin. Consult a healthcare provider before use, especially if taking medications. Limited safety data for pregnancy and breastfeeding.",
"sourceUrl": "https://www.nccih.nih.gov/health/elderberry"
},
{
"slug": "feverfew",
"name": "Feverfew",
"commonNames": [
"Tanacetum parthenium"
],
"conditions": [
"headache"
],
"uses": "Feverfew is promoted for migraine headache prevention and for minor head and tension pain. Topically, it is marketed for itching and skin irritation.",
"evidence": "A 2020 systematic review of seven migraine studies found inconsistent results, though some evidence suggests feverfew may reduce migraine frequency and associated symptoms such as nausea and light sensitivity. There is little or no evidence supporting feverfew for other health conditions.",
"cautions": "Side effects include nausea, digestive issues, bloating, and mouth sores from chewing fresh leaves. People sensitive to ragweed may experience allergic reactions. Feverfew may slow blood clotting and should be stopped at least 2 weeks before scheduled surgery. It may interact with migraine medications and should be avoided during pregnancy due to potential effects on uterine contractions.",
"sourceUrl": "https://www.nccih.nih.gov/health/feverfew"
},
{
"slug": "ginger",
"name": "Ginger",
"commonNames": [
"Zingiber officinale"
],
"conditions": [
"nausea-vomiting",
"menstrual-cramps",
"indigestion"
],
"uses": "Ginger is traditionally and commonly used for nausea and vomiting, indigestion, menstrual cramps, and osteoarthritis.",
"evidence": "Research shows ginger may be helpful for nausea and vomiting associated with pregnancy. Studies suggest ginger dietary supplements might be helpful for reducing the severity of menstrual cramps and for knee osteoarthritis symptoms; effectiveness for chemotherapy- or post-surgery-related nausea remains uncertain.",
"cautions": "Side effects when taken orally include abdominal discomfort, heartburn, diarrhea, and mouth and throat irritation. Possible interactions with medications exist; consult a healthcare provider before use, especially during pregnancy or breastfeeding.",
"sourceUrl": "https://www.nccih.nih.gov/health/ginger"
},
{
"slug": "goldenseal",
"name": "Goldenseal",
"commonNames": [
"Hydrastis canadensis"
],
"conditions": [
"common-cold",
"diarrhea",
"wounds-cuts"
],
"uses": "Goldenseal is marketed for the common cold and upper respiratory infections, diarrhea, constipation, and other digestive conditions. Historically, Native Americans used it for digestive disorders, wounds, and skin conditions.",
"evidence": "There is not enough evidence to determine whether goldenseal is useful for any health condition; no rigorous studies have been done in people. Very little of the active compound berberine is absorbed when goldenseal is taken orally, making findings from berberine studies potentially inapplicable.",
"cautions": "Short-term use at approximately 3 grams daily appears to be without serious adverse effects, though longer-term safety is uncertain. Goldenseal significantly affects drug metabolism; one study found it decreased metformin levels by about 25%. Some commercial products contain unlisted ingredients or substitute herbs. Avoid during pregnancy, breastfeeding, and in infants due to potential harm from berberine.",
"sourceUrl": "https://www.nccih.nih.gov/health/goldenseal"
},
{
"slug": "horse-chestnut",
"name": "Horse Chestnut",
"commonNames": [
"Aesculus hippocastanum"
],
"conditions": [
"hemorrhoids"
],
"uses": "Horse chestnut seed extract is promoted for chronic venous insufficiency (leg pain and swelling from poor circulation) and has historically been used for hemorrhoids, arthritis, and menstrual cramps.",
"how": "Use only standardized horse chestnut seed extract (a dietary supplement) — never raw seeds, bark, flowers, or leaves, which are toxic when taken orally. Research has used standardized extract for up to 12 weeks; follow the product label and consult a healthcare provider before use.",
"evidence": "A 2012 systematic review found horse chestnut seed extract can improve symptoms of chronic venous insufficiency, comparable to compression stockings in one study; however, more rigorous trials are needed. Evidence for other uses, including hemorrhoids, is insufficient.",
"cautions": "Raw seeds, bark, flowers, and leaves are unsafe to take orally due to toxic components; only standardized extracts with the toxin removed should be used, and research shows safety for up to 12 weeks. Side effects include dizziness, digestive upset, headache, and itching. Safety during pregnancy and breastfeeding is unknown; consult a healthcare provider before use.",
"sourceUrl": "https://www.nccih.nih.gov/health/horse-chestnut"
},
{
"slug": "lavender",
"name": "Lavender",
"commonNames": [
"Lavandula angustifolia"
],
"conditions": [
"sleeplessness"
],
"uses": "Lavender is promoted as an oral supplement and aromatherapy agent for calming anxiety, stress, and sleep difficulties; it is also used topically and in aromatherapy for depression symptoms and pain.",
"how": "Lavender is used as an oral supplement (capsule or tea), as an aromatherapy oil (inhaled or diffused), or applied topically to skin. Oral lavender oil products have been studied for anxiety; aromatherapy involves inhaling the scent from a diffuser or a few drops on a cloth.",
"evidence": "Studies suggest oral lavender oil might be beneficial for anxiety, including anxiety with co-occurring depression; evidence for sleep improvement is insufficient, and it is unclear whether aromatherapy with lavender benefits anxiety, stress, or depression.",
"cautions": "Oral lavender products may cause diarrhea, headache, nausea, or burping. Topical application can trigger allergic skin reactions. Potential interactions with sedative drugs warrant discussion with a healthcare provider. Safety during pregnancy and breastfeeding is unknown.",
"sourceUrl": "https://www.nccih.nih.gov/health/lavender"
},
{
"slug": "licorice-root",
"name": "Licorice Root",
"commonNames": [
"Glycyrrhiza glabra"
],
"conditions": [
"canker-sores",
"sore-throat",
"burns",
"skin-rash-itch"
],
"uses": "Licorice root is promoted for digestive and respiratory support and is used topically for skin conditions. Research has focused on mouth rinses for canker sores, gargles or lozenges for sore throat prevention, and topical gels for eczema and burn healing.",
"how": "For canker sores, licorice is used as a mouth rinse or gargle; for sore throat prevention around surgery, as a gargle or lozenge; for eczema or burn healing, as a topical gel applied to the skin. Avoid long-term or high-dose oral use due to serious cardiovascular risks.",
"evidence": "Preliminary studies suggest licorice mouth rinses may reduce canker sore pain and size, and lozenges or gargles may prevent sore throat after surgical intubation. Topical gels show some promise for eczema symptoms and burn healing, though more research is needed. There is not enough high-quality evidence to support its use for most conditions.",
"cautions": "Licorice contains glycyrrhizin, which can cause serious adverse effects including irregular heartbeat and cardiac arrest, especially with long-term or large-dose use. Even small amounts can be risky for people with high blood pressure, high salt intake, or heart or kidney conditions. Large amounts during pregnancy increase miscarriage risk and are considered unsafe. Interactions with corticosteroids have been documented; consult a healthcare provider before use.",
"sourceUrl": "https://www.nccih.nih.gov/health/licorice-root"
},
{
"slug": "passionflower",
"name": "Passionflower",
"commonNames": [
"Passiflora incarnata"
],
"conditions": [
"sleeplessness"
],
"uses": "Passionflower is marketed as a dietary supplement for anxiety, sleep problems, and stress.",
"how": "Passionflower is taken as a tea (studied for up to 7 nights) or as an oral extract supplement (studied for up to 8 weeks). Follow package directions and consult a healthcare provider before use, especially if you take other medications or are scheduled for surgery.",
"evidence": "Some research suggests oral passionflower may help reduce anxiety symptoms and improve total sleep time in adults with insomnia, though findings on falling or staying asleep are mixed and conclusions are not definite.",
"cautions": "Taking passionflower alongside anesthesia or other surgical medications may slow the nervous system too much; avoid use before surgery. Should not be used during pregnancy as it may induce uterine contractions. Possible side effects include drowsiness, dizziness, and confusion. Discuss with a healthcare provider before combining with medications.",
"sourceUrl": "https://www.nccih.nih.gov/health/passionflower"
},
{
"slug": "peppermint-oil",
"name": "Peppermint Oil",
"commonNames": [
"Mentha x piperita"
],
"conditions": [
"headache",
"nausea-vomiting",
"indigestion"
],
"uses": "Peppermint oil is promoted for irritable bowel syndrome, indigestion, headaches, muscle tension, and nausea. Topical application is used for tension headaches, and inhaled peppermint oil is used for nausea.",
"how": "For IBS, peppermint oil is taken as enteric-coated capsules (which reduce heartburn compared to plain capsules). For tension headaches, apply peppermint oil topically to the skin. For nausea, peppermint oil is inhaled as aromatherapy.",
"evidence": "A 2022 review found peppermint oil was better than placebo at improving overall IBS symptoms and abdominal pain. A 2024 review concluded that inhaling peppermint oil was particularly effective at reducing nausea and vomiting in patients with cancer. Evidence suggests potential benefit for tension headaches when applied topically, though peppermint oil taken alone may worsen indigestion in some people.",
"cautions": "Oral side effects may include heartburn, nausea, abdominal pain, and dry mouth; enteric-coated capsules reduce heartburn risk. Never apply topically to infants' or young children's faces due to menthol's respiratory effects. Skin irritation is possible with topical use. Consult a healthcare provider before use with medications, as interactions may occur.",
"sourceUrl": "https://www.nccih.nih.gov/health/peppermint-oil"
},
{
"slug": "tea-tree-oil",
"name": "Tea Tree Oil",
"commonNames": [
"Melaleuca alternifolia"
],
"conditions": [
"fungal-infection",
"acne",
"wounds-cuts",
"insect-bites-stings",
"burns"
],
"uses": "Tea tree oil is marketed for external use including acne, athlete's foot, toenail fungus, and lice. Traditional Aboriginal uses included treating wounds, burns, and insect bites.",
"how": "Apply tea tree oil topically to the affected skin area only — for acne or athlete's foot, use a product containing tea tree oil as directed on the label. Never swallow tea tree oil; oral ingestion can cause serious symptoms including confusion, unsteadiness, and coma.",
"evidence": "A small amount of research suggests tea tree oil might be helpful for acne and athlete's foot (fungal infection), though more studies are needed. Evidence for toenail fungus, lice, eyelid inflammation, and oral health uses is insufficient or uncertain. Traditional wound, burn, and insect bite uses lack rigorous clinical research.",
"cautions": "Critical warning: tea tree oil must not be swallowed — oral ingestion can cause serious symptoms including confusion, unsteadiness, inability to walk, and coma. Topical use is generally safe for most adults, though some experience skin redness or irritation. Older products or those exposed to heat, light, or air may increase reaction risk. Products appear safe during pregnancy and breastfeeding when used topically only.",
"sourceUrl": "https://www.nccih.nih.gov/health/tea-tree-oil"
},
{
"slug": "turmeric",
"name": "Turmeric",
"commonNames": [
"Curcuma longa",
"curcumin"
],
"conditions": [
"muscle-joint-pain",
"indigestion",
"skin-rash-itch"
],
"uses": "Turmeric is promoted for osteoarthritis, itching, depression, allergies, and high cholesterol; topical applications target joint pain. Historically, it was used in traditional medicine for indigestion, colds, skin infections, and liver disease.",
"how": "Turmeric is taken orally as a dietary supplement or applied topically to the skin. Conventionally formulated oral turmeric or curcumin supplements are considered likely safe in recommended amounts for up to 2 to 3 months; follow the product label and consult a healthcare provider before use.",
"evidence": "For osteoarthritis, initial evidence is positive for knee pain relief and joint function, though higher-quality research is needed. For most other claimed uses, researchers cannot definitively confirm turmeric's effectiveness, and overall evidence remains inconclusive.",
"cautions": "Standard turmeric formulations are likely safe in recommended amounts for up to 23 months. Common side effects include nausea, vomiting, acid reflux, and digestive issues. Enhanced bioavailability products have caused liver damage in some users; symptoms include fatigue, dark urine, and jaundice. Use during pregnancy may be unsafe; breastfeeding safety is unclear. Consult a healthcare provider before use with medications.",
"sourceUrl": "https://www.nccih.nih.gov/health/turmeric"
},
{
"slug": "valerian",
"name": "Valerian",
"commonNames": [
"Valeriana officinalis"
],
"conditions": [
"sleeplessness"
],
"uses": "Valerian is promoted for insomnia, anxiety, stress, and depression; historically, it was also used for migraine, fatigue, and stomach issues.",
"how": "Valerian is taken orally as a dietary supplement; clinical studies have used doses of 300 to 600 mg daily for up to 6 weeks. Follow the product label and consult a healthcare provider before use, particularly if combining with sedatives or alcohol.",
"evidence": "Evidence is limited and inconsistent. The American Academy of Sleep Medicine recommended against using valerian for chronic insomnia in adults (2017). Studies on anxiety, menstrual cramps, and other conditions show inadequate evidence of benefit.",
"cautions": "Appears generally safe for short-term use at 300600 mg daily for up to 6 weeks; long-term safety is unknown. Common side effects include headache, stomach upset, mental dullness, and vivid dreams. Abrupt discontinuation after long-term use may cause withdrawal symptoms including anxiety and insomnia. Avoid combining with alcohol or sedatives. Safety during pregnancy and breastfeeding is unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/valerian"
}
]
}

View File

@ -30,7 +30,17 @@ export class ContentAutoUpdateJob {
const result = await contentAutoUpdateService.attempt()
logger.info(`[ContentAutoUpdateJob] ${result.started} started: ${result.reason}`)
return result
// The FDA drug dataset refreshes on the same cycle and master switch as the
// ZIM/map catalog (its apply path differs, so it's a separate step, not part
// of attempt()). Governed by the same `contentAutoUpdate.*` settings.
const drugResult = await contentAutoUpdateService.attemptDrugDataset()
logger.info(`[ContentAutoUpdateJob] ${drugResult.started} started: ${drugResult.reason}`)
return {
started: result.started + drugResult.started,
reason: `${result.reason}; ${drugResult.reason}`,
}
}
static async schedule() {

View File

@ -0,0 +1,345 @@
import { Job } from 'bullmq'
import { access, mkdir, constants } from 'node:fs/promises'
import logger from '@adonisjs/core/services/logger'
import { QueueService } from '#services/queue_service'
import { doResumableDownload } from '../utils/downloads.js'
import { parseDrugLabelManifest, partZipPath, manifestBytesTotal } from '../../util/drug_labels.js'
import type {
DownloadDrugDataJobParams,
DrugLabelManifest,
DownloadStateMarker,
DrugDatasetResourceMeta,
} from '../../types/drug_reference.js'
/** Where all part zips are staged on the bind-mounted storage volume. */
export const STORAGE_BASE = '/app/storage/drug-data'
const MANIFEST_URL = 'https://api.fda.gov/download.json'
/**
* Phase A Download (network-only failure domain).
*
* Pass 0 fetches the openFDA manifest; each later pass downloads ONE part to
* disk (resumable via doResumableDownload, so a worker restart resumes from the
* .tmp rather than re-downloading). Continuations use queue.add with NO jobId
* the same rule as the embed/ingest chains, so BullMQ dedupe doesn't swallow the
* next part against the lingering parent. After the last part lands we write the
* `drugReference.downloadState` KV marker and, when auto-chaining, dispatch the
* ingest phase. Parts are NEVER deleted here they persist until a full ingest
* succeeds so the manual "Ingest into search" path can re-run from disk.
*/
export class DownloadDrugDataJob {
static get queue() {
return 'drug-download'
}
static get key() {
return 'download-drug-data'
}
/** Deterministic jobId — only one download at a time, re-runnable. */
static get jobId() {
return 'drug-data-download'
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Dispatch the initial download (pass 0). Idempotent on the deterministic
* jobId. A finished/failed prior job under that id is cleared first so the
* "Download FDA data" button can always restart (resume is handled at the
* file level by doResumableDownload).
*
* @param autoChain - dispatch the ingest phase after the last part. Default true.
* @param resourceMeta - install-state identity when the install came through a
* curated tier. Carried through the chain so the ingest job writes the
* `installed_resources` row on `ready`. Omit for a manual download (no row).
*/
static async dispatch(autoChain = true, resourceMeta?: DrugDatasetResourceMeta) {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const existing = await queue.getJob(this.jobId)
if (existing) {
const state = await existing.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return { job: existing, created: false, message: 'Drug data download already running' }
}
try {
await existing.remove()
} catch {
// Best-effort: fall through to add, which surfaces any genuine conflict.
}
}
try {
const job = await queue.add(
this.key,
{ autoChain, resourceMeta } satisfies DownloadDrugDataJobParams,
{
jobId: this.jobId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 5 },
removeOnFail: { count: 5 },
}
)
return { job, created: true, message: 'Drug data download dispatched' }
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error)
if (msg.includes('job already exists')) {
const stillThere = await queue.getJob(this.jobId)
return { job: stillThere, created: false, message: 'Drug data download already running' }
}
throw error
}
}
static async getJob(): Promise<Job | undefined> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
return await queue.getJob(this.jobId)
}
// ─── Job handler ───────────────────────────────────────────────────────────
async handle(job: Job) {
const params = job.data as DownloadDrugDataJobParams
const partIndex = params.partIndex ?? 0
const autoChain = params.autoChain ?? true
const startedAt = params.startedAt ?? Date.now()
const resourceMeta = params.resourceMeta
logger.info(`[DownloadDrugDataJob] Starting pass partIndex=${partIndex}`)
// Pre-flight: storage drive writable.
await this.verifyStorageAvailable(job)
// Pass 0: fetch the manifest.
let manifest: DrugLabelManifest
if (partIndex === 0 || !params.manifest) {
await job.updateData({
...job.data,
phase: 'manifest',
partIndex: 0,
totalParts: 0,
currentPartName: null,
autoChain,
startedAt,
})
await job.updateProgress(0)
logger.info('[DownloadDrugDataJob] Fetching manifest from api.fda.gov/download.json')
manifest = await this.fetchManifest()
logger.info(
`[DownloadDrugDataJob] Manifest: export_date=${manifest.export_date} ` +
`total_records=${manifest.total_records} parts=${manifest.partitions.length}`
)
} else {
manifest = params.manifest
}
const totalParts = params.totalParts ?? manifest.partitions.length
if (partIndex >= totalParts) {
logger.warn(
`[DownloadDrugDataJob] partIndex ${partIndex} >= totalParts ${totalParts}, nothing to do`
)
return
}
const partition = manifest.partitions[partIndex]
const zipPath = partZipPath(STORAGE_BASE, partition)
const partName = partition.display_name || partition.file
logger.info(
`[DownloadDrugDataJob] Downloading part ${partIndex + 1}/${totalParts}: ${partName}`
)
await job.updateData({
...job.data,
phase: 'downloading',
partIndex,
totalParts,
currentPartName: partName,
manifest,
autoChain,
startedAt,
bytesDownloaded: 0,
})
await job.updateProgress(Math.floor((partIndex / totalParts) * 100))
await mkdir(STORAGE_BASE, { recursive: true })
// Aggregate-across-parts byte accounting for the Active Downloads card. The
// drug job fans the manifest's N partitions into BullMQ continuations, but
// the user sees ONE download — so progress is reported as bytes across the
// whole set, not the current part. `priorPartsBytes` is the actual bytes of
// the parts already on disk (from the running recordedParts list);
// `manifestTotalBytes` is the sum of every partition's manifest size_mb.
const priorRecorded =
(params as { recordedParts?: DownloadStateMarker['parts'] }).recordedParts ?? []
const priorPartsBytes = priorRecorded.reduce((acc, p) => acc + (p.bytes || 0), 0)
const manifestTotalBytes = manifestBytesTotal(manifest)
logger.info(`[DownloadDrugDataJob] ${partition.file}${zipPath}`)
let partBytes = 0
await doResumableDownload({
url: partition.file,
filepath: zipPath,
timeout: 300_000, // 5-minute per-chunk timeout
allowedMimeTypes: [], // skip MIME check — zip content-type varies across CDNs
onProgress: (progress) => {
partBytes = progress.downloadedBytes
const downloadFraction = progress.downloadedBytes / (progress.totalBytes || 1)
const pct = Math.floor(((partIndex + downloadFraction) / totalParts) * 100)
const downloadedBytes = priorPartsBytes + progress.downloadedBytes
// Emit the canonical {percent, downloadedBytes, totalBytes} object the
// Active Downloads aggregator + the byte/speed readout expect (the old
// bare-int progress couldn't carry bytes). parseProgress in
// DownloadService still tolerates a bare int for back-compat, but only
// the object surfaces a live byte/speed readout. Fire-and-forget; swallow
// transient reject so it can't crash the worker.
void job
.updateProgress({
percent: pct,
downloadedBytes,
totalBytes: manifestTotalBytes,
lastProgressTime: Date.now(),
})
.catch(() => {})
void job.updateData({ ...job.data, bytesDownloaded: progress.downloadedBytes }).catch(() => {})
},
})
logger.info(`[DownloadDrugDataJob] Download complete: ${zipPath} (${partBytes} bytes)`)
// Record this part for the download-state marker (written after the last one).
const recordedParts =
(params as { recordedParts?: DownloadStateMarker['parts'] }).recordedParts ?? []
recordedParts.push({ index: partIndex, name: partName, path: zipPath, bytes: partBytes })
const nextIndex = partIndex + 1
if (nextIndex < totalParts) {
// Continuation — NO jobId (let BullMQ auto-generate). The critical rule.
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(DownloadDrugDataJob.queue)
const continuationParams: DownloadDrugDataJobParams & {
recordedParts: DownloadStateMarker['parts']
} = {
partIndex: nextIndex,
manifest,
totalParts,
autoChain,
startedAt,
recordedParts,
resourceMeta,
}
await queue.add(DownloadDrugDataJob.key, continuationParams, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 5 },
removeOnFail: { count: 5 },
})
logger.info(
`[DownloadDrugDataJob] Dispatched continuation for part ${nextIndex + 1}/${totalParts}`
)
} else {
// Last part — write the download-state marker. Parts stay on disk until a
// full ingest succeeds; do NOT delete them here.
await this.writeDownloadState(manifest, totalParts, recordedParts)
await job.updateData({
...job.data,
phase: 'downloaded',
partIndex,
totalParts,
currentPartName: null,
})
await job.updateProgress(100)
logger.info(
`[DownloadDrugDataJob] All ${totalParts} parts downloaded. ` +
`export_date=${manifest.export_date}`
)
if (autoChain) {
const { IngestDrugDataJob } = await import('#jobs/ingest_drug_data_job')
// Forward the install-state identity so the ingest job writes the
// `installed_resources` row on `ready`. undefined for a manual download.
await IngestDrugDataJob.dispatch(resourceMeta)
logger.info('[DownloadDrugDataJob] Auto-chained ingest phase')
}
}
return { partIndex, totalParts }
}
// ─── Private helpers ───────────────────────────────────────────────────────
private async verifyStorageAvailable(job: Job): Promise<void> {
try {
await access(STORAGE_BASE, constants.W_OK)
} catch {
try {
await mkdir(STORAGE_BASE, { recursive: true })
} catch (mkdirErr) {
await job.updateData({ ...job.data, phase: 'failed' })
throw new Error(
`Storage drive not available: cannot write to ${STORAGE_BASE} (${
mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr)
})`
)
}
}
}
private async fetchManifest(): Promise<DrugLabelManifest> {
return DownloadDrugDataJob.fetchManifest()
}
/**
* Fetch + parse the openFDA download manifest. The SINGLE source of truth for
* the openFDA manifest call (Maxim 4): the download job uses it on pass 0, and
* the freshness check (DrugReferenceService.checkForUpdate, driven by
* attemptAutoUpdate) reuses it so there is exactly one place that knows the URL and the
* offline-error translation.
*/
static async fetchManifest(): Promise<DrugLabelManifest> {
let json: unknown
try {
const resp = await fetch(MANIFEST_URL)
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} from ${MANIFEST_URL}`)
}
json = await resp.json()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (
msg.includes('ENOTFOUND') ||
msg.includes('ECONNREFUSED') ||
msg.includes('ECONNRESET') ||
msg.includes('fetch failed')
) {
throw new Error(`No internet — connect to download FDA drug data. (${msg})`)
}
throw err
}
return parseDrugLabelManifest(json)
}
private async writeDownloadState(
manifest: DrugLabelManifest,
totalParts: number,
parts: DownloadStateMarker['parts']
): Promise<void> {
const KVStore = (await import('#models/kv_store')).default
const marker: DownloadStateMarker = {
export_date: manifest.export_date,
totalParts,
totalRecords: manifest.total_records,
parts,
completedAtMs: Date.now(),
}
await KVStore.setValue('drugReference.downloadState', JSON.stringify(marker))
}
}

View File

@ -0,0 +1,837 @@
import { Job } from 'bullmq'
import { promises as fsPromises } from 'node:fs'
import { access, constants } from 'node:fs/promises'
import { Writable } from 'node:stream'
import logger from '@adonisjs/core/services/logger'
import { QueueService } from '#services/queue_service'
import { mapDrugLabelRecord, parseDownloadState, partZipPath } from '../../util/drug_labels.js'
import { STORAGE_BASE } from '#jobs/download_drug_data_job'
import type {
IngestDrugDataJobParams,
DrugLabelManifest,
DrugLabelPartition,
DrugDatasetResourceMeta,
} from '../../types/drug_reference.js'
const BATCH_SIZE = 500
/**
* Dedupe a batch by set_id, CASE-INSENSITIVELY (last occurrence wins). MySQL's
* uniq_drug_labels_set_id index uses a case-insensitive collation, so two ids
* differing only in case are ONE row to the database the dedupe key must agree
* with the database's notion of equality or a batch can still collide with
* itself.
*/
function dedupeBySetId(
rows: ReturnType<typeof mapDrugLabelRecord>[]
): NonNullable<ReturnType<typeof mapDrugLabelRecord>>[] {
const bySetId = new Map<string, NonNullable<ReturnType<typeof mapDrugLabelRecord>>>()
for (const r of rows) {
if (r) bySetId.set(r.set_id.toLowerCase(), r)
}
return [...bySetId.values()]
}
/**
* Upsert one batch with MySQL-native `INSERT … ON DUPLICATE KEY UPDATE`
* (knex onConflict().merge()) instead of Lucid's updateOrCreateMany.
*
* WHY: updateOrCreateMany SELECTs existing rows and matches them to incoming
* rows IN JAVASCRIPT a case-sensitive string compare. The DB's unique key on
* set_id is case-INsensitive, so when openFDA ships the same set_id with
* different casing across parts (seen live: part 5's '93A0696B-' colliding
* with an earlier part's variant), Lucid misses the match, INSERTs, and the
* unique key rejects it aborting the run. A native upsert makes the unique
* key itself the arbiter: same-key rows update, new rows insert, intra-batch
* duplicates take the update path. No JS equality anywhere.
*
* `ingested_at` is stamped explicitly (raw knex bypasses Lucid's autoCreate);
* merge() refreshes every inserted column on conflict, preserving the previous
* "re-ingest updates the row + timestamp" behavior.
*/
async function upsertDrugLabelBatch(
rows: NonNullable<ReturnType<typeof mapDrugLabelRecord>>[]
): Promise<number> {
if (rows.length === 0) return 0
const { default: db } = await import('@adonisjs/lucid/services/db')
const now = new Date()
const withTs = rows.map((r) => ({ ...r, ingested_at: now }))
await db.knexQuery().table('drug_labels').insert(withTs).onConflict('set_id').merge()
return rows.length
}
// A single 500-row updateOrCreateMany should finish in seconds even on the
// FULLTEXT-indexed drug_labels table. If one batch exceeds this, the DB is
// locked/overloaded — reject loudly so the ingest FAILS VISIBLY (and retries via
// BullMQ) instead of hanging forever with the worker's lock still renewing,
// which reads in the UI as a frozen "part 1 of 13, 0 rows". 0.2.714 removed the
// un-awaited-update worker crash; this removes the remaining silent hang.
const UPSERT_TIMEOUT_MS = 120_000
// No record parsed within this window (the zip-open + JSON-parse stage, BEFORE the
// first batch) means the part is almost certainly corrupt/truncated: yauzl's
// inflate stream hangs with no 'end' and no 'error', so the ingest sat on
// "part 1, 0 rows" forever with the worker's lock still renewing. Fail loud
// instead. Once records flow, the per-batch upsert timeout governs.
const STALL_MS = 90_000
/**
* updateOrCreateMany with a hard timeout. mysql2 has no default query timeout, so
* a stuck DB call (metadata lock, exhausted connection pool, FULLTEXT stall)
* would otherwise never settle and the part-stream would back-pressure to a halt.
* Promise.race turns that into a rejection the caller can log + fail + retry.
*/
async function withUpsertTimeout<T>(
work: Promise<T>,
rowCount: number,
timeoutMs: number
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`updateOrCreateMany timed out after ${timeoutMs}ms (batch of ${rowCount} rows) — ` +
'the database may be locked or overloaded'
)
),
timeoutMs
)
})
try {
return await Promise.race([work, timeout])
} finally {
if (timer) clearTimeout(timer)
}
}
// ─── Local type aliases for yauzl callbacks ───────────────────────────────────
// yauzl/stream-json are loaded via dynamic import() with @ts-ignore (see
// streamIngestPart). The real @types ship in devDependencies and resolve on the
// target machine; loading them lazily keeps the pure util/ helpers importable in
// tests without the streaming deps. These local interfaces give the callback
// parameters explicit types without importing yauzl's own types (which aren't
// resolvable in the inertia tsconfig context).
import type { Readable } from 'node:stream'
interface YauzlEntry { fileName: string }
interface YauzlZipFile {
readEntry(): void
openReadStream(entry: YauzlEntry, cb: (err: Error | null, stream: Readable | null) => void): void
on(event: 'entry', listener: (entry: YauzlEntry) => void): this
on(event: 'end', listener: () => void): this
on(event: 'error', listener: (err: Error) => void): this
}
/**
* Phase B Ingest (parse/DB-only failure domain, ZERO network I/O).
*
* Each pass reads ONE on-disk part and streams it into drug_labels via the
* memory-safe streamIngestPart pipeline (yauzl stream-json batched
* updateOrCreateMany). The part list comes from the manifest in job data OR, for
* a manual "Ingest into search" run with no manifest, is rebuilt from the
* `drugReference.downloadState` KV marker. A missing on-disk part fails loudly
* ("run Download first") rather than silently under-ingesting. Continuations use
* queue.add with NO jobId. After the LAST part: write the final KV status, then
* delete the downloaded parts and clear the download-state marker (the per-part
* unlink that used to run during download moves here parts persist until a
* full ingest succeeds).
*/
export class IngestDrugDataJob {
static get queue() {
return 'drug-ingest'
}
static get key() {
return 'ingest-drug-data'
}
/** Deterministic jobId — only one ingest at a time, re-runnable. */
static get jobId() {
return 'drug-labels-ingest'
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Dispatch the initial ingest (pass 0). Idempotent on the deterministic jobId.
* A finished/failed prior job under that id is cleared first so a re-ingest can
* always restart (upserts are idempotent on set_id).
*
* @param resourceMeta - install-state identity, present only when the install
* came through a curated tier. Carried through the chain so the final pass
* writes the `installed_resources` row on `ready`. Absent on a manual ingest.
*/
static async dispatch(resourceMeta?: DrugDatasetResourceMeta) {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const existing = await queue.getJob(this.jobId)
if (existing) {
const state = await existing.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return { job: existing, created: false, message: 'Drug label ingest already running' }
}
try {
await existing.remove()
} catch {
// Best-effort: fall through to add.
}
}
try {
const job = await queue.add(
this.key,
{ resourceMeta } satisfies IngestDrugDataJobParams,
{
jobId: this.jobId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 5 },
removeOnFail: { count: 5 },
}
)
return { job, created: true, message: 'Drug label ingest dispatched' }
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error)
if (msg.includes('job already exists')) {
const stillThere = await queue.getJob(this.jobId)
return { job: stillThere, created: false, message: 'Drug label ingest already running' }
}
throw error
}
}
static async getJob(): Promise<Job | undefined> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
return await queue.getJob(this.jobId)
}
// ─── Job handler ───────────────────────────────────────────────────────────
async handle(job: Job) {
const params = job.data as IngestDrugDataJobParams
const partIndex = params.partIndex ?? 0
const runningIngested = params.recordsIngested ?? 0
const runningSkipped = params.recordsSkipped ?? 0
const startedAt = params.startedAt ?? Date.now()
const resourceMeta = params.resourceMeta
// Progress baseline (pass 0 only): the table's row count BEFORE this run.
// Without it, a re-ingest into a populated table shows ~100% from second
// zero because the live row count dominates the shown counter.
let startRowCount = params.startRowCount
if (startRowCount === undefined) {
try {
const { default: db } = await import('@adonisjs/lucid/services/db')
const result = await db.rawQuery('SELECT COUNT(*) AS cnt FROM drug_labels')
const rows = result[0] as Array<{ cnt: number | string }>
startRowCount = Number(rows[0]?.cnt ?? 0)
} catch {
startRowCount = 0
}
}
logger.info(`[IngestDrugDataJob] Starting pass partIndex=${partIndex}`)
// Resolve the part list: manifest in job data, else the KV download marker.
const { manifest, exportDate } = await this.resolvePartSource(params)
const totalParts = params.totalParts ?? manifest.partitions.length
if (partIndex >= totalParts) {
logger.warn(
`[IngestDrugDataJob] partIndex ${partIndex} >= totalParts ${totalParts}, nothing to do`
)
return
}
const partition = manifest.partitions[partIndex]
const zipPath = partZipPath(STORAGE_BASE, partition)
const partName = partition.display_name || partition.file
// Guard: the part MUST already be on disk. No re-download here — fail loud so
// a missing part can't silently produce a "ready" status with fewer rows.
try {
await access(zipPath, constants.R_OK)
} catch {
await job.updateData({ ...job.data, phase: 'failed' })
throw new Error(
`Part ${partIndex + 1}/${totalParts} not downloaded (${zipPath}). ` +
'Run Download FDA data first.'
)
}
logger.info(
`[IngestDrugDataJob] Ingesting part ${partIndex + 1}/${totalParts}: ${partName}`
)
await job.updateData({
...job.data,
phase: 'ingesting',
partIndex,
totalParts,
currentPartName: partName,
recordsIngested: runningIngested,
recordsSkipped: runningSkipped,
manifest,
startedAt,
startRowCount,
})
await job.updateProgress(Math.floor((partIndex / totalParts) * 100))
const { recordsIngested: partIngested, recordsSkipped: partSkipped } =
await this.streamIngestPart(
job,
zipPath,
partIndex,
totalParts,
runningIngested,
runningSkipped
)
const totalIngested = runningIngested + partIngested
const totalSkipped = runningSkipped + partSkipped
logger.info(
`[IngestDrugDataJob] Part ${partIndex + 1} done: ` +
`ingested=${partIngested} skipped=${partSkipped} running_total=${totalIngested}`
)
const nextIndex = partIndex + 1
if (nextIndex < totalParts) {
// Continuation — NO jobId. The critical rule.
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(IngestDrugDataJob.queue)
const continuationParams: IngestDrugDataJobParams = {
partIndex: nextIndex,
manifest,
totalParts,
recordsIngested: totalIngested,
recordsSkipped: totalSkipped,
startedAt,
startRowCount,
resourceMeta,
}
await queue.add(IngestDrugDataJob.key, continuationParams, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 5 },
removeOnFail: { count: 5 },
})
logger.info(`[IngestDrugDataJob] Dispatched continuation for part ${nextIndex + 1}/${totalParts}`)
await job.updateData({
...job.data,
phase: 'ingesting',
partIndex,
totalParts,
recordsIngested: totalIngested,
recordsSkipped: totalSkipped,
})
} else {
// Final part — write KV status, mark ready, THEN reclaim disk.
await this.writeFinalStatus(exportDate, resourceMeta)
await job.updateData({
...job.data,
phase: 'ready',
partIndex,
totalParts,
currentPartName: null,
recordsIngested: totalIngested,
recordsSkipped: totalSkipped,
})
await job.updateProgress(100)
logger.info(
`[IngestDrugDataJob] Ingest complete. ` +
`total_ingested=${totalIngested} total_skipped=${totalSkipped} ` +
`export_date=${exportDate}`
)
// Reclaim disk only after a FULL ingest succeeds.
await this.deleteDownloadedParts(manifest, totalParts)
}
return { partIndex, totalIngested, totalSkipped }
}
// ─── Private helpers ───────────────────────────────────────────────────────
/**
* Resolve the ordered partition list + export_date for this ingest run.
*
* Prefers the manifest carried in job data (auto-chained from the download job,
* or a continuation pass). Falls back to the KV download-state marker so a
* manual "Ingest into search" with no manifest still works the marker stores
* each part's on-disk path and manifest index, which is enough to drive
* streamIngestPart without re-fetching the manifest from the network. Fails
* loudly if neither source is present.
*/
private async resolvePartSource(
params: IngestDrugDataJobParams
): Promise<{ manifest: DrugLabelManifest; exportDate: string }> {
if (params.manifest) {
return { manifest: params.manifest, exportDate: params.manifest.export_date }
}
const KVStore = (await import('#models/kv_store')).default
const marker = parseDownloadState(await KVStore.getValue('drugReference.downloadState'))
if (!marker) {
throw new Error('Nothing downloaded — run Download FDA data first.')
}
// Rebuild a manifest-shaped partition list from the marker. partZipPath uses
// path.basename(partition.file), so feeding the recorded path as `file`
// resolves back to the same on-disk path.
const ordered = [...marker.parts].sort((a, b) => a.index - b.index)
const partitions: DrugLabelPartition[] = ordered.map((p) => ({
display_name: p.name,
file: p.path,
size_mb: '0',
records: 0,
}))
const manifest: DrugLabelManifest = {
export_date: marker.export_date,
// The marker persists the real manifest total (~259k) so a rebuilt
// manifest carries the same label-count denominator the auto-chained run
// would have had — keeping the "X of ~259k" counter, the records-based
// progress %, and the ETA alive on the manual-ingest path. Pre-totalRecords
// markers parse back as 0; the service treats 0 as unknown and falls back.
total_records: marker.totalRecords,
partitions,
}
return { manifest, exportDate: marker.export_date }
}
/**
* Stream-unzip the part, stream-parse the JSON, batch-upsert into drug_labels.
*
* Memory-safe: never loads the full JSON into memory.
* Pipeline: yauzl entry read-stream stream-json Pick+StreamArray Writable batching.
* Back-pressure: the Writable's `write()` method calls `callback()` only after
* the DB upsert resolves, so Node's stream machinery naturally pauses the upstream
* pipe chain when BATCH_SIZE is reached and an upsert is in flight.
*/
private async streamIngestPart(
job: Job,
zipPath: string,
partIndex: number,
totalParts: number,
runningIngested: number,
runningSkipped: number
): Promise<{ recordsIngested: number; recordsSkipped: number }> {
// Dynamic imports for the streaming deps (yauzl, stream-json). @ts-ignore
// covers local dev where node_modules hasn't been refreshed with the new
// deps yet; it is a no-op once the real @types are installed on the target
// machine, and it also covers stream-json's deep `.js` subpaths that the
// @types package doesn't map. Importing here (not at module top) keeps the
// pure util/ helpers loadable in tests without these deps.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — yauzl resolved at runtime from dependencies
const yauzl = await import('yauzl')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — stream-json resolved at runtime from dependencies
const createParser = (await import('stream-json')).default.parser
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — stream-json deep subpath resolved at runtime
const createPick = (await import('stream-json/filters/Pick.js')).default.pick
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — stream-json deep subpath resolved at runtime
const createStreamArray = (await import('stream-json/streamers/StreamArray.js')).default.streamArray
// Defensive: stream-json 1.9.1 (CJS) exposes its factories only as
// `.default.parser` / `.default.pick` / `.default.streamArray` under ESM
// dynamic import — there is NO `parser`/`pick`/`streamArray` named export, so
// destructuring those silently yielded `undefined`. Calling an undefined
// "factory" then threw inside the yauzl openReadStream callback (not a
// promise), the process-level backstop swallowed the uncaughtException, the
// streamIngestPart promise never settled, and the 90s watchdog fired in a
// retry loop with zero records. THIS was the real ingest hang. Validate the
// imports here so any future interop slip fails loud in the async body.
if (
typeof createParser !== 'function' ||
typeof createPick !== 'function' ||
typeof createStreamArray !== 'function'
) {
throw new Error(
'stream-json factory imports did not resolve to functions ' +
`(parser=${typeof createParser}, pick=${typeof createPick}, streamArray=${typeof createStreamArray})`
)
}
let recordsIngested = 0
let recordsSkipped = 0
let batch: ReturnType<typeof mapDrugLabelRecord>[] = []
let batchNum = 0
let firstRecordSeen = false
// Cast to `any` so callback parameters get explicit annotations below rather
// than triggering implicit-any in tsconfigs that don't find the yauzl types.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const yauzlOpen = (yauzl as any).open as (
path: string,
opts: { lazyEntries: boolean; autoClose: boolean },
cb: (err: Error | null, zipFile: YauzlZipFile | null) => void
) => void
return new Promise<{ recordsIngested: number; recordsSkipped: number }>((resolveRaw, rejectRaw) => {
// Stall watchdog. The per-batch upsert has a timeout, but the stage BEFORE
// the first record (zip-open + JSON parse) had none — a corrupt/truncated
// part makes yauzl's inflate stream hang with no 'end' and no 'error', so
// the ingest froze on "part 1, 0 rows". If no record is parsed within
// STALL_MS, fail the part loudly (the reason lands in the job's failedReason
// → the status panel) and destroy the stuck stream. resolve/reject are
// wrapped (shadowing the raw executor params) so every existing handler
// routes through the once-guard + watchdog cleanup.
let settled = false
let activeReadStream: Readable | null = null
let watchdog: ReturnType<typeof setInterval> | undefined
const watchStart = Date.now()
const settle = () => {
settled = true
if (watchdog) clearInterval(watchdog)
try {
activeReadStream?.destroy()
} catch {
// best-effort stream teardown
}
}
const resolve = (v: { recordsIngested: number; recordsSkipped: number }) => {
if (settled) return
settle()
resolveRaw(v)
}
const reject = (e: Error) => {
if (settled) return
settle()
rejectRaw(e)
}
watchdog = setInterval(() => {
if (!settled && !firstRecordSeen && Date.now() - watchStart > STALL_MS) {
reject(
new Error(
`Ingest stalled: no records parsed from part ${partIndex + 1}/${totalParts} ` +
`within ${Math.round(STALL_MS / 1000)}s. The downloaded part is likely corrupt ` +
'or truncated — re-download FDA data, then ingest again.'
)
)
}
}, 10_000)
yauzlOpen(zipPath, { lazyEntries: true, autoClose: true }, (err, zipFile) => {
if (err || !zipFile) {
reject(err ?? new Error(`Failed to open zip: ${zipPath}`))
return
}
zipFile.on('error', reject)
zipFile.readEntry()
zipFile.on('entry', (entry) => {
// Skip directory entries
if (/\/$/.test(entry.fileName)) {
zipFile.readEntry()
return
}
// Open the single JSON entry as a read stream — never buffer it
zipFile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
reject(streamErr ?? new Error(`Could not open zip entry ${entry.fileName}`))
return
}
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: reading zip entry ${entry.fileName}`
)
activeReadStream = readStream
// The JSON envelope is { meta, results: [...] }
// Pick the `results` path → StreamArray emits one record at a time
const jsonParser = createParser({ jsonStreaming: false })
const pick = createPick({ filter: 'results' })
const streamArray = createStreamArray()
// Writable that accumulates batches and flushes with back-pressure.
// The callback is called only after the async upsert completes, which
// naturally applies back-pressure via the pipe chain.
const batchWriter = new Writable({
objectMode: true,
write(chunk: { value: unknown }, _encoding: BufferEncoding, callback: (err?: Error | null) => void) {
const record = chunk.value
if (!firstRecordSeen) {
firstRecordSeen = true
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: first record received from parser`
)
}
// Map the record
let row: ReturnType<typeof mapDrugLabelRecord>
try {
row = mapDrugLabelRecord(record as Parameters<typeof mapDrugLabelRecord>[0])
} catch (mapErr) {
logger.warn(
`[IngestDrugDataJob] mapDrugLabelRecord threw: ${mapErr instanceof Error ? mapErr.message : String(mapErr)}`
)
recordsSkipped++
callback()
return
}
if (!row) {
recordsSkipped++
callback()
return
}
batch.push(row)
if (batch.length < BATCH_SIZE) {
// Not full yet — don't block the stream
callback()
return
}
// Batch full — flush and hold the callback until the upsert
// resolves (this is the back-pressure point).
const currentBatch = dedupeBySetId(batch)
batch = []
const myBatch = ++batchNum
const t0 = Date.now()
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: upserting batch ${myBatch} (${currentBatch.length} rows)…`
)
withUpsertTimeout(
upsertDrugLabelBatch(currentBatch),
currentBatch.length,
UPSERT_TIMEOUT_MS
)
.then((rowCount) => {
recordsIngested += rowCount
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: batch ${myBatch} ok in ${Date.now() - t0}ms ` +
`(${rowCount} rows; part running ${recordsIngested})`
)
// Update progress: parts-done fraction + within-part fraction
const withinFraction = recordsIngested / Math.max(1, 20000)
const pct = Math.floor(
((partIndex + withinFraction) / totalParts) * 100
)
// Fire-and-forget progress writes. Swallow transient Redis/
// job-update rejections: an un-awaited reject would otherwise
// bubble to an unhandledRejection and crash the worker, which
// BullMQ then reports as "job stalled more than allowable
// limit" (a dead worker stops renewing its lock).
void job
.updateProgress(
Math.min(pct, Math.floor(((partIndex + 1) / totalParts) * 100) - 1)
)
.catch(() => {})
void job
.updateData({
...job.data,
recordsIngested: runningIngested + recordsIngested,
recordsSkipped: runningSkipped + recordsSkipped,
})
.catch(() => {})
callback()
})
.catch((upsertErr: unknown) => {
const msg = upsertErr instanceof Error ? upsertErr.message : String(upsertErr)
if (/timed out/i.test(msg)) {
// Systemic DB hang — fail the part loudly so BullMQ retries.
logger.error(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: batch ${myBatch} TIMED OUT after ${Date.now() - t0}ms: ${msg}`
)
callback(upsertErr instanceof Error ? upsertErr : new Error(msg))
return
}
// Per-batch data error (a row the schema rejects, etc.) — log,
// count as skipped, and CONTINUE. One bad batch must not abort
// the whole ~259k ingest (over-correcting to fail-loud here is
// what let a single duplicate set_id kill the run at part 5).
logger.error(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: batch ${myBatch} skipped after error (${Date.now() - t0}ms): ${msg}`
)
recordsSkipped += currentBatch.length
callback()
})
},
final(callback: (err?: Error | null) => void) {
// Flush the last partial batch
if (batch.length === 0) {
callback()
return
}
const remainingBatch = dedupeBySetId(batch)
batch = []
const t0 = Date.now()
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: upserting final batch (${remainingBatch.length} rows)…`
)
withUpsertTimeout(
upsertDrugLabelBatch(remainingBatch),
remainingBatch.length,
UPSERT_TIMEOUT_MS
)
.then((rowCount) => {
recordsIngested += rowCount
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: final batch ok in ${Date.now() - t0}ms (${rowCount} rows)`
)
callback()
})
.catch((upsertErr: unknown) => {
const msg = upsertErr instanceof Error ? upsertErr.message : String(upsertErr)
if (/timed out/i.test(msg)) {
logger.error(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: final batch TIMED OUT after ${Date.now() - t0}ms: ${msg}`
)
callback(upsertErr instanceof Error ? upsertErr : new Error(msg))
return
}
logger.error(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: final batch skipped after error (${Date.now() - t0}ms): ${msg}`
)
recordsSkipped += remainingBatch.length
callback()
})
},
})
batchWriter.on('finish', () => {
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: stream finished — ` +
`ingested=${recordsIngested} skipped=${recordsSkipped} batches=${batchNum}`
)
resolve({ recordsIngested, recordsSkipped })
})
batchWriter.on('error', reject)
readStream.on('error', reject)
jsonParser.on('error', reject)
pick.on('error', reject)
streamArray.on('error', reject)
// Pipeline: readStream → jsonParser → pick → streamArray → batchWriter
readStream.pipe(jsonParser).pipe(pick).pipe(streamArray).pipe(batchWriter)
})
})
zipFile.on('end', () => {
// All zip entries enumerated. The batchWriter 'finish' event resolves
// the promise once the last batch flushes.
})
})
})
}
private async writeFinalStatus(
exportDate: string,
resourceMeta?: DrugDatasetResourceMeta
): Promise<void> {
// Lazy import to keep module top level free of Lucid
const KVStore = (await import('#models/kv_store')).default
await KVStore.setValue('drugReference.lastUpdatedExportDate', exportDate)
// Install-state write-back: when this ingest was kicked off by a curated-tier
// install, record an `installed_resources` row so the tier-status math (which
// is row-driven for ZIM/map) recognizes the dataset uniformly — the home-tile
// gate and the tier "installed" badge both read these rows. resource_type
// 'dataset' was widened onto the model in the foundational slice. The row's
// `version` is the openFDA export_date (the real freshness key), not the
// manifest placeholder. Manual downloads pass no resourceMeta → no row, which
// is correct: install-state belongs to the curated-tier path only.
if (!resourceMeta) return
try {
const { default: InstalledResource } = await import('#models/installed_resource')
const { DateTime } = await import('luxon')
const totalBytes = await this.totalDownloadedBytes()
await InstalledResource.updateOrCreate(
{ resource_id: resourceMeta.resourceId, resource_type: 'dataset' },
{
version: exportDate,
collection_ref: resourceMeta.collectionRef,
url: 'https://api.fda.gov/download.json',
// No single on-disk file — the parts are deleted after ingest; the
// installed artifact is the DB table. Record the staging dir for
// provenance; uninstall keys off resource_id, never this path.
file_path: STORAGE_BASE,
file_size_bytes: totalBytes,
installed_at: DateTime.now(),
}
)
logger.info(
`[IngestDrugDataJob] Wrote installed_resources row for ${resourceMeta.resourceId} (export_date=${exportDate})`
)
} catch (err) {
// A failed row write must NOT abort a completed ingest — the data is
// already searchable. Log loud; the tier badge will simply read
// not-installed until the next install reconcile.
logger.error(
`[IngestDrugDataJob] Failed to write installed_resources row for ${resourceMeta.resourceId}: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
/**
* Sum the recorded part sizes from the download-state KV marker, for the
* `installed_resources.file_size_bytes` column. Best-effort: the parts are
* deleted right after a full ingest, so the marker (written before deletion) is
* the only durable size source. Returns null when the marker is absent/empty so
* the column stays NULL rather than reporting a wrong 0.
*/
private async totalDownloadedBytes(): Promise<number | null> {
try {
const KVStore = (await import('#models/kv_store')).default
const { parseDownloadState } = await import('../../util/drug_labels.js')
const marker = parseDownloadState(await KVStore.getValue('drugReference.downloadState'))
if (!marker || marker.parts.length === 0) return null
const sum = marker.parts.reduce((acc, p) => acc + (p.bytes || 0), 0)
return sum > 0 ? sum : null
} catch {
return null
}
}
/**
* Delete the downloaded part zips and clear the download-state marker, run once
* after a full ingest succeeds (reclaims ~1.7 GB). A failed unlink is logged
* but never aborts a completed ingest.
*/
private async deleteDownloadedParts(
manifest: DrugLabelManifest,
totalParts: number
): Promise<void> {
for (let i = 0; i < totalParts; i++) {
const partition = manifest.partitions[i]
if (!partition) continue
const zipPath = partZipPath(STORAGE_BASE, partition)
try {
await fsPromises.unlink(zipPath)
logger.info(`[IngestDrugDataJob] Deleted zip: ${zipPath}`)
} catch (err) {
logger.warn(
`[IngestDrugDataJob] Could not delete zip ${zipPath}: ${err instanceof Error ? err.message : String(err)}`
)
}
}
const KVStore = (await import('#models/kv_store')).default
await KVStore.clearValue('drugReference.downloadState')
}
}

View File

@ -0,0 +1,116 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
/**
* Drug Reference v1 openFDA drug label catalog entry.
*
* One row per FDA `set_id` (stable GUID for a labeling, across all revisions).
* Re-ingesting updates existing rows idempotently via `set_id` UNIQUE key.
*
* Enum-ish columns (product_type) are plain varchars validated at the edge
* the stl_files / inventory_items convention: no native DB enums, so the
* schema can evolve without ALTER TABLE.
*
* All section-text columns are optional: label records frequently omit
* sections (e.g. OTC labels have no `boxed_warning`). The mapper returns
* null for any absent or empty section.
*/
export default class DrugLabel extends BaseModel {
static table = 'drug_labels'
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
/** openFDA set_id — stable GUID across label revisions. Idempotent upsert key. */
@column()
declare set_id: string
/** openFDA id — per-revision GUID for provenance. */
@column()
declare spl_id: string | null
@column()
declare version: string | null
/** openfda.brand_name[0] — first element only. */
@column()
declare brand_name: string | null
/** openfda.generic_name joined with ", " (labels can list several). */
@column()
declare generic_name: string | null
/** openfda.manufacturer_name[0]. */
@column()
declare manufacturer: string | null
/** openfda.product_ndc joined with ", ". */
@column()
declare product_ndc: string | null
/** openfda.route joined with ", ". */
@column()
declare route: string | null
/**
* openfda.product_type[0]. Drives OTC vs Rx badge + filter.
* Expected values: 'HUMAN OTC DRUG' | 'HUMAN PRESCRIPTION DRUG'.
*/
@column()
declare product_type: string | null
/**
* Normalized brand+generic blob for FULLTEXT and LIKE search.
* Built by normalizeDrugName() at ingest time; never re-computed on read.
* Max 768 chars stays inside InnoDB utf8mb4 index key-length budget.
*/
@column()
declare searchable_name: string | null
/** Flattened indications_and_usage sections, joined with \n\n. */
@column()
declare indications: string | null
/** Flattened dosage_and_administration sections. */
@column()
declare dosage: string | null
/** Flattened warnings sections. */
@column()
declare warnings: string | null
/** Flattened boxed_warning sections (absent on most OTC labels). */
@column()
declare boxed_warning: string | null
/**
* Flattened drug_interactions label text.
* Single-drug label info only NOT a pairwise cross-drug checker.
*/
@column()
declare drug_interactions: string | null
/** Flattened contraindications sections. */
@column()
declare contraindications: string | null
/** Flattened when_using sections (common on OTC labels). */
@column()
declare when_using: string | null
/** Flattened stop_use sections (common on OTC labels). */
@column()
declare stop_use: string | null
/**
* Parsed from effective_time (YYYYMMDD YYYY-MM-DD).
* Stored as a plain date string, not a Luxon DateTime, because
* the source format is just a date (no time component).
*/
@column()
declare source_updated_at: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true, columnName: 'ingested_at' })
declare ingested_at: DateTime
}

View File

@ -11,7 +11,7 @@ export default class InstalledResource extends BaseModel {
declare resource_id: string
@column()
declare resource_type: 'zim' | 'map'
declare resource_type: 'zim' | 'map' | 'dataset'
@column()
declare collection_ref: string | null

View File

@ -98,15 +98,28 @@ export class CollectionManifestService {
const spec = await this.getSpecWithFallback<ZimCategoriesSpec>('zim_categories')
if (!spec) return []
const installedResources = await InstalledResource.query().where('resource_type', 'zim')
// Include 'dataset' rows alongside 'zim' so a curated tier carrying the FDA
// drug dataset reads "installed" once the ingest writes its row (the tier-
// status math below treats every resolved resource id uniformly — a dataset
// resource is just another id to account for).
const installedResources = await InstalledResource.query().whereIn('resource_type', [
'zim',
'dataset',
])
const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))
// In-flight ZIM download resource IDs from the BullMQ queue. Used to
// surface the user's tier intent immediately on submit, before any single
// file has finished downloading. Failed jobs are excluded so a stuck
// queue entry doesn't keep claiming the user's pick forever.
// In-flight ZIM + dataset download resource IDs from the BullMQ queues. Used
// to surface the user's tier intent immediately on submit, before any single
// file has finished downloading. Failed jobs are excluded so a stuck queue
// entry doesn't keep claiming the user's pick forever.
const inFlightIds = await this.getInFlightZimResourceIds()
// Whether the in-flight drug dataset is in its INGEST (indexing) phase — the
// download finished and the heavy ingest is running. Lets the wizard card
// flip "(downloading)" → "(indexing)" at the handoff (Req 7) instead of
// showing a stale "downloading" through the long index.
const drugIndexing = await this.isDrugDatasetIndexing()
return spec.categories.map((category) => {
const installedTierSlug = this.getInstalledTierForCategory(category.tiers, installedMap)
const downloadingTierSlug = this.getDownloadingTierForCategory(
@ -115,10 +128,47 @@ export class CollectionManifestService {
inFlightIds,
installedTierSlug
)
return { ...category, installedTierSlug, downloadingTierSlug }
// Only mark "indexing" when this category's downloading tier actually
// carries a dataset resource that is the one indexing.
const downloadingTierIndexing =
drugIndexing && downloadingTierSlug
? CollectionManifestService.resolveTierResources(
category.tiers.find((t) => t.slug === downloadingTierSlug)!,
category.tiers
).some((r) => r.type === 'dataset')
: false
return { ...category, installedTierSlug, downloadingTierSlug, downloadingTierIndexing }
})
}
/**
* True when the FDA drug dataset's INGEST is in flight while its DOWNLOAD is
* not i.e. the handoff into the indexing phase. Drives the wizard card's
* "(indexing)" label. Defensive: any queue read failure returns false (the card
* just keeps showing "(downloading)") rather than breaking the categories list.
*/
private async isDrugDatasetIndexing(): Promise<boolean> {
try {
const { DownloadDrugDataJob } = await import('#jobs/download_drug_data_job')
const { IngestDrugDataJob } = await import('#jobs/ingest_drug_data_job')
const queueService = QueueService.getInstance()
const ingestQueue = queueService.getQueue(IngestDrugDataJob.queue)
const ingestJobs = await ingestQueue.getJobs(['active', 'waiting', 'delayed'])
if (ingestJobs.length === 0) return false
const downloadQueue = queueService.getQueue(DownloadDrugDataJob.queue)
const downloadJobs = await downloadQueue.getJobs(['active', 'waiting', 'delayed'])
return downloadJobs.length === 0
} catch (error: any) {
logger.warn(
'[CollectionManifestService] Could not determine drug indexing state:',
error?.message || error
)
return false
}
}
private async getInFlightZimResourceIds(): Promise<Set<string>> {
const ids = new Set<string>()
try {
@ -134,9 +184,47 @@ export class CollectionManifestService {
// unreachable — just report no in-flight downloads.
logger.warn('[CollectionManifestService] Could not read download queue:', error?.message || error)
}
// Also surface an in-flight curated-tier drug-dataset install. The drug
// download/ingest run on their own queues (not RunDownloadJob), carrying the
// dataset's resource id in resourceMeta — read it so the wizard shows the
// Medicine tier as "downloading" the moment the user opts in, mirroring the
// ZIM behaviour. Scanned independently so a missing drug queue can't blank
// the ZIM in-flight set above.
await this.addInFlightDrugDatasetId(ids)
return ids
}
/**
* Add the FDA drug dataset's manifest resource id to `ids` when its download or
* ingest is in flight. The dataset only counts as "downloading" while no
* install-state row exists yet once ingest writes the row it is "installed"
* (handled by the installedMap), so a still-running ingest correctly reads as
* the in-flight tier intent here. resourceMeta is only present on a curated-tier
* install, so a manual (non-tier) drug download never claims a tier slug.
*/
private async addInFlightDrugDatasetId(ids: Set<string>): Promise<void> {
try {
const { DownloadDrugDataJob } = await import('#jobs/download_drug_data_job')
const { IngestDrugDataJob } = await import('#jobs/ingest_drug_data_job')
const queueService = QueueService.getInstance()
for (const queueName of [DownloadDrugDataJob.queue, IngestDrugDataJob.queue]) {
const queue = queueService.getQueue(queueName)
const jobs = await queue.getJobs(['waiting', 'active', 'delayed'])
for (const job of jobs) {
const resourceId = job.data?.resourceMeta?.resourceId
if (typeof resourceId === 'string') ids.add(resourceId)
}
}
} catch (error: any) {
logger.warn(
'[CollectionManifestService] Could not read drug dataset queue:',
error?.message || error
)
}
}
/**
* Highest tier whose every resource is installed OR has an in-flight
* download. Returns undefined when there are no in-flight downloads for this

View File

@ -24,7 +24,10 @@ export class CollectionUpdateService {
* state (version + cool-off anchor) so the auto-updater can act on it later.
*/
async checkForUpdates(): Promise<ContentUpdateCheckResult> {
const installed = await InstalledResource.all()
// ZIM/map catalog update path only — exclude `dataset` resources (e.g. the
// FDA drug labels), which are not filename-versioned and get their own
// freshness path. No-op today (no dataset rows are written in this slice).
const installed = await InstalledResource.query().whereNot('resource_type', 'dataset')
if (installed.length === 0) {
return {
updates: [],
@ -35,7 +38,11 @@ export class CollectionUpdateService {
try {
const catalog = new KiwixCatalogService()
const latestByKey = await catalog.getLatestForResources(
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type }))
// `dataset` rows are filtered out above, so the type narrows to ZIM/map.
installed.map((r) => ({
resource_id: r.resource_id,
resource_type: r.resource_type as 'zim' | 'map',
}))
)
const now = DateTime.now()
@ -47,7 +54,7 @@ export class CollectionUpdateService {
if (latest && latest.version > resource.version) {
updates.push({
resource_id: resource.resource_id,
resource_type: resource.resource_type,
resource_type: resource.resource_type as 'zim' | 'map',
installed_version: resource.version,
latest_version: latest.version,
download_url: latest.download_url,

View File

@ -0,0 +1,329 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { CONDITIONS_FILE } from '../data/conditions.js'
import { NATURAL_REMEDIES_FILE } from '../data/natural_remedies.js'
import { HOME_REMEDIES_FILE } from '../data/home_remedies.js'
import {
parseConditionsFile,
parseNaturalRemediesFile,
findConditionBySlug,
toConditionSummary,
buildIndicationQuery,
orderOtcFirst,
remediesForCondition,
remediesForFreeText,
} from '../../util/conditions.js'
import { PRODUCT_TYPES } from '../../types/drug_reference.js'
import type {
Condition,
ConditionSummary,
ConditionDrugsResult,
NaturalRemedy,
NaturalRemediesFile,
} from '../../types/conditions.js'
import type { DrugSearchResult } from '../../types/drug_reference.js'
/**
* "When to use what" condition-first service (Phase 1 + Phase 2).
*
* Resolves a curated condition (or a free-text situation) to the OTC drugs whose
* FDA label indications match it, plus the natural remedies (NCCIH) whose curated
* condition mapping includes the resolved slug.
*
* Reuses the Drug Reference indication-search machinery: the same combined-FULLTEXT
* index (ft_drug_labels_name_indications), the same MAX(MATCH ) aggregate pattern
* required under MySQL 8 ONLY_FULL_GROUP_BY, and the same brand+generic collapse
* then re-buckets results OTC-first.
*
* Natural remedies are in-memory (no DB table, no migration) one module-level
* parse of NATURAL_REMEDIES_FILE, reused across all requests.
*/
/**
* Module-level merged remedies corpus (fail-soft): the NCCIH herbs plus the
* non-herbal home-care measures (CDC/NIH/FDA), each entry tagged with its kind
* so the UI can badge them apart. Slugs are disjoint between the two files
* (validated at curation time).
*/
const HERB_FILE = parseNaturalRemediesFile(NATURAL_REMEDIES_FILE)
const HOME_FILE = parseNaturalRemediesFile(HOME_REMEDIES_FILE)
const REMEDIES_FILE: NaturalRemediesFile = {
version: HERB_FILE.version,
source: HERB_FILE.source,
remedies: [
...HERB_FILE.remedies.map((r) => ({ ...r, kind: 'herb' as const })),
...HOME_FILE.remedies.map((r) => ({ ...r, kind: 'self-care' as const })),
],
}
export class ConditionService {
/** Parsed (fail-soft) curated spine — bad entries are dropped, not fatal. */
private get spine(): Condition[] {
return parseConditionsFile(CONDITIONS_FILE).conditions
}
/** All curated conditions as client-facing summaries (no searchTerms). */
listConditions(): ConditionSummary[] {
return this.spine.map(toConditionSummary)
}
/**
* The full curated natural-remedies list (NCCIH herbs). Small enough to ship
* as page props, which lets the Drug Reference search match remedies by name
* client-side and offer a "Natural" browse with no extra round trip.
*/
listRemedies(): NaturalRemedy[] {
return REMEDIES_FILE.remedies
}
/**
* The full curated spine (WITH searchTerms) for server-side matching such as
* the drug-detail reverse link (situationsForIndications). Stays server-only
* searchTerms are a search-implementation detail the client never receives.
*/
allConditions(): Condition[] {
return this.spine
}
/** Find a curated condition by slug. Returns null when absent. */
findCondition(slug: string): Condition | null {
return findConditionBySlug(this.spine, slug)
}
/**
* Resolve a curated condition (by slug) to its matching OTC drugs and natural
* remedies. Returns null when the slug is not in the curated spine so the
* controller can 404. Drugs are OTC-only and OTC-first-ordered.
*/
async drugsForSlug(
slug: string,
limit = 50,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<ConditionDrugsResult | null> {
const condition = this.findCondition(slug)
if (!condition) return null
const drugs = await this.searchIndications(condition.searchTerms, limit, opts)
const remedies: NaturalRemedy[] = remediesForCondition(REMEDIES_FILE, slug)
return { condition: toConditionSummary(condition), drugs, remedies }
}
/**
* Resolve a free-text situation (off-list condition) to matching OTC drugs and
* natural remedies. Treats the raw query as a single search term. Returns a
* synthetic condition summary echoing the query so the UI can render a consistent
* header.
*
* Remedy resolution for free text:
* 1. Try to resolve the query to a curated condition slug (exact or substring
* match on slug/label). If found, use remediesForCondition this is the
* primary path (e.g. "burns" typed free-form still finds aloe/tea-tree).
* 2. Secondary fallback: remediesForFreeText searches remedy name/uses by
* substring so an unmapped query ("athlete's foot") can still surface a
* relevant remedy. Results from both paths are unioned and de-duped by slug.
*/
async drugsForFreeText(
query: string,
limit = 50,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<ConditionDrugsResult> {
const trimmed = query.trim()
const drugs = trimmed.length > 0 ? await this.searchIndications([trimmed], limit, opts) : []
// Phase 2: union condition-mapped + free-text-matched remedies.
const remedyMap = new Map<string, NaturalRemedy>()
if (trimmed.length > 0) {
// Primary: resolve to a curated condition slug the same way the client-side
// matchSituation helper does (case-insensitive exact/substring on slug+label).
const q = trimmed.toLowerCase()
const matchedCondition =
this.spine.find((c) => c.slug.toLowerCase() === q || c.label.toLowerCase() === q) ??
this.spine.find(
(c) =>
c.label.toLowerCase().includes(q) || c.slug.replace(/-/g, ' ').toLowerCase().includes(q)
) ??
null
if (matchedCondition) {
for (const r of remediesForCondition(REMEDIES_FILE, matchedCondition.slug)) {
remedyMap.set(r.slug, r)
}
}
// Secondary: name/uses substring fallback.
for (const r of remediesForFreeText(REMEDIES_FILE, trimmed)) {
if (!remedyMap.has(r.slug)) remedyMap.set(r.slug, r)
}
}
return {
condition: { slug: '', label: trimmed, category: 'Search' },
drugs,
remedies: Array.from(remedyMap.values()),
}
}
/**
* Core search: FULLTEXT over (searchable_name, indications) for the OR-expanded
* searchTerms, OTC-filtered, collapsed by brand+generic, OTC-first-ordered.
*
* Strategy mirrors DrugReferenceService:
* 1. FULLTEXT NATURAL LANGUAGE MODE on the built query (requires the query
* to have a >= 3-char token; innodb_ft_min_token_size = 3).
* 2. LIKE fallback when the built query is empty/too short OR FULLTEXT throws
* (e.g. the index is absent) runs each term as a LIKE clause.
* Both paths force product_type = OTC and then orderOtcFirst (a no-op on the
* already-OTC set, but kept so a future relaxation of the OTC filter stays
* correctly ordered).
*/
async searchIndications(
searchTerms: string[],
limit = 50,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<DrugSearchResult[]> {
const ftQuery = buildIndicationQuery(searchTerms)
// A FULLTEXT query needs a token >= innodb_ft_min_token_size (3). If the
// longest bare token is shorter, NATURAL LANGUAGE MODE returns nothing, so
// go straight to LIKE.
const longestToken = ftQuery
.replace(/"/g, ' ')
.split(/\s+/)
.reduce((max, t) => Math.max(max, t.length), 0)
const useFulltext = ftQuery.length > 0 && longestToken >= 3
if (useFulltext) {
try {
const rows = await this.searchIndicationFulltext(ftQuery, limit, opts)
return orderOtcFirst(rows)
} catch (err) {
logger.warn(
`[ConditionService] FULLTEXT indication search failed, falling back to LIKE: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
const rows = await this.searchIndicationLike(searchTerms, limit, opts)
return orderOtcFirst(rows)
}
/**
* FULLTEXT OTC indication search.
*
* MATCHes over (searchable_name, indications) must exactly match the
* ft_drug_labels_name_indications index column list. The MAX(MATCH ) wrapper
* is LOAD-BEARING: MySQL 8 ONLY_FULL_GROUP_BY rejects a bare MATCH() in SELECT
* under GROUP BY; wrapping it in MAX() makes it an aggregate. Copied verbatim
* from DrugReferenceService.searchIndicationFulltext, with a fixed OTC filter.
*/
private async searchIndicationFulltext(
ftQuery: string,
limit: number,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<DrugSearchResult[]> {
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount,
MAX(MATCH(searchable_name, indications) AGAINST(? IN NATURAL LANGUAGE MODE)) AS relevance
FROM drug_labels
WHERE MATCH(searchable_name, indications) AGAINST(? IN NATURAL LANGUAGE MODE)
AND product_type = ?
`
const bindings: unknown[] = [ftQuery, ftQuery, PRODUCT_TYPES.OTC]
if (opts?.route) {
sql += ' AND route LIKE ?'
bindings.push(`%${opts.route.toUpperCase()}%`)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY ${opts?.sort === 'name' ? 'COALESCE(brand_name, generic_name) ASC' : 'relevance DESC'}
LIMIT ?
`
bindings.push(limit)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
/**
* LIKE OTC indication fallback (FULLTEXT unavailable or query too short).
*
* ORs a LIKE clause per search term over `indications` so any synonym can
* surface a match. OTC-filtered and collapsed identically to the FULLTEXT path.
*/
private async searchIndicationLike(
searchTerms: string[],
limit: number,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<DrugSearchResult[]> {
const terms = searchTerms.map((t) => t.trim()).filter((t) => t.length > 0)
if (terms.length === 0) return []
const likeClauses = terms.map(() => 'indications LIKE ?').join(' OR ')
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount
FROM drug_labels
WHERE (${likeClauses})
AND product_type = ?
`
const bindings: unknown[] = [...terms.map((t) => `%${t}%`), PRODUCT_TYPES.OTC]
if (opts?.route) {
sql += ' AND route LIKE ?'
bindings.push(`%${opts.route.toUpperCase()}%`)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY ${opts?.sort === 'name' ? 'COALESCE(brand_name, generic_name) ASC' : 'brand_name ASC'}
LIMIT ?
`
bindings.push(limit)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
/** Map raw collapsed rows to DrugSearchResult — mirrors DrugReferenceService. */
private mapSearchRows(rows: any[]): DrugSearchResult[] {
if (!Array.isArray(rows)) return []
return rows.map((row) => ({
id: Number(row.id),
brand_name: row.brand_name ?? null,
generic_name: row.generic_name ?? null,
manufacturer: row.manufacturer ?? null,
route: row.route ?? null,
product_type: row.product_type ?? null,
labelCount: Number(row.labelCount ?? row.labelcount ?? 1),
}))
}
/**
* Current drug_labels row count drives the index page empty-state (no data
* point the user to Drug Reference to download first). Mirrors
* DrugReferenceService.rowCount; returns 0 on any error.
*/
async drugRowCount(): Promise<number> {
try {
const result = await db.rawQuery('SELECT COUNT(*) AS cnt FROM drug_labels')
const rows = result[0] as Array<{ cnt: number | string }>
return Number(rows[0]?.cnt ?? 0)
} catch {
return 0
}
}
}

View File

@ -4,6 +4,7 @@ import KVStore from '#models/kv_store'
import InstalledResource from '#models/installed_resource'
import { DownloadService } from '#services/download_service'
import { CollectionUpdateService } from '#services/collection_update_service'
import { DrugReferenceService } from '#services/drug_reference_service'
import {
KiwixCatalogService,
reconcileResourceUpdateState,
@ -269,9 +270,18 @@ export class ContentAutoUpdateService {
await this.maybeResetWindowBudget(config, now)
// Local catalog check + persist available-update state for every resource.
const installed = await InstalledResource.all()
// ZIM/map catalog path only — `dataset` resources are excluded here because
// their freshness key (openFDA `export_date`) and apply path (the drug
// download/ingest chain) don't fit the catalog-version model. They refresh
// via `attemptDrugDataset()`, which the same content-update job runs under
// the same master switch + window.
const installed = await InstalledResource.query().whereNot('resource_type', 'dataset')
const latestByKey = await this.catalog.getLatestForResources(
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type }))
// `dataset` rows are filtered out above, so the type narrows to ZIM/map.
installed.map((r) => ({
resource_id: r.resource_id,
resource_type: r.resource_type as 'zim' | 'map',
}))
)
for (const resource of installed) {
const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null
@ -321,7 +331,8 @@ export class ContentAutoUpdateService {
const result = await this.collectionUpdateService.applyUpdate(
{
resource_id: candidate.resource.resource_id,
resource_type: candidate.resource.resource_type,
// `dataset` rows are filtered out of `installed` above, so ZIM/map.
resource_type: candidate.resource.resource_type as 'zim' | 'map',
installed_version: candidate.resource.version,
latest_version: candidate.version,
download_url: candidate.download_url,
@ -372,6 +383,39 @@ export class ContentAutoUpdateService {
}
}
/**
* Freshness pass for the FDA drug dataset (`resource_type` 'dataset'), run by
* the same hourly ContentAutoUpdateJob as the ZIM/map `attempt()` and gated on
* the SAME master switch + window. The dataset's apply path differs from the
* catalog loop (openFDA `export_date` + the drug download/ingest chain rather
* than an InstalledResource catalog-version row), so it runs as its own step
* instead of through `selectUnderCap` but sharing `contentAutoUpdate.*` means
* it never updates while content auto-update is off, and refreshes alongside
* ZIMs and maps when it is on. Isolated so a drug-side failure can't affect the
* catalog run.
*/
async attemptDrugDataset(): Promise<{ started: number; reason: string }> {
const config = await this.getConfig()
if (!config.enabled) {
return { started: 0, reason: 'Content auto-update is disabled' }
}
if (!isWithinWindow(config.windowStart, config.windowEnd, DateTime.now())) {
return {
started: 0,
reason: `Outside update window (${config.windowStart}-${config.windowEnd})`,
}
}
try {
const result = await new DrugReferenceService().attemptAutoUpdate()
return { started: result.started ? 1 : 0, reason: `drug dataset: ${result.reason}` }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn(`[ContentAutoUpdateService] Drug dataset freshness check failed: ${message}`)
return { started: 0, reason: `drug dataset check failed: ${message}` }
}
}
/**
* Evaluate what the next run *would* do, without hitting the network,
* persisting state, or dispatching anything. Operates on the available-update
@ -407,7 +451,9 @@ export class ContentAutoUpdateService {
const now = overrides.now ?? DateTime.now()
const withinWindow = isWithinWindow(config.windowStart, config.windowEnd, now)
const pending = await InstalledResource.query().whereNotNull('available_update_version')
const pending = await InstalledResource.query()
.whereNotNull('available_update_version')
.whereNot('resource_type', 'dataset')
const eligible = pending.filter(
(r) => this.resourceEligibility(r, config.cooloffHours, now).eligible
)
@ -506,7 +552,9 @@ export class ContentAutoUpdateService {
const config = await this.getConfig()
const now = DateTime.now()
const pending = await InstalledResource.query().whereNotNull('available_update_version')
const pending = await InstalledResource.query()
.whereNotNull('available_update_version')
.whereNot('resource_type', 'dataset')
const resources: ContentAutoUpdateResourceStatus[] = pending.map((resource) => {
const verdict = this.resourceEligibility(resource, config.cooloffHours, now)
const size = resource.available_update_size_bytes ?? null
@ -514,7 +562,8 @@ export class ContentAutoUpdateService {
config.maxBytesPerWindow > 0 && size !== null && size > config.maxBytesPerWindow
return {
resource_id: resource.resource_id,
resource_type: resource.resource_type,
// `dataset` rows are filtered out of `pending` above, so ZIM/map.
resource_type: resource.resource_type as 'zim' | 'map',
current_version: resource.version,
available_update_version: resource.available_update_version,
size_bytes: size,

View File

@ -4,6 +4,7 @@ import { RunDownloadJob } from '#jobs/run_download_job'
import { RunExtractPmtilesJob } from '#jobs/run_extract_pmtiles_job'
import type { RunExtractPmtilesJobParams } from '#jobs/run_extract_pmtiles_job'
import { DownloadModelJob } from '#jobs/download_model_job'
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { DownloadJobWithProgress, DownloadProgressData } from '../../types/downloads.js'
import type { Job, Queue } from 'bullmq'
import { normalize } from 'path'
@ -51,10 +52,11 @@ export class DownloadService {
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
const [fileTagged, extractTagged, modelJobs] = await Promise.all([
const [fileTagged, extractTagged, modelJobs, drugTagged] = await Promise.all([
this.fetchJobsWithStates(RunDownloadJob.queue),
this.fetchJobsWithStates(RunExtractPmtilesJob.queue),
modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed']),
this.fetchJobsWithStates(DownloadDrugDataJob.queue),
])
const fileDownloads = fileTagged.map(({ job, state }) => {
@ -101,7 +103,40 @@ export class DownloadService {
failedReason: job.failedReason || undefined,
}))
const allDownloads = [...fileDownloads, ...extractDownloads, ...modelDownloads]
// FDA drug dataset — DOWNLOAD phase only, collapsed to ONE card. The job fans
// the manifest's N partitions into continuations under AUTO-GENERATED jobIds
// (only part 0 runs under the deterministic jobId), and the queue is
// concurrency 1, so at most one part is ever in flight. Filtering to the
// deterministic jobId would track only part 0 and then drop the card while
// parts 2..N keep downloading. Instead represent the whole download with the
// single in-flight job's aggregate progress (the progress emit already spans
// all parts), and always report the deterministic jobId so the cancel/remove
// button routes to _cancelDrugDownloadJob whichever part is active. The heavy
// ingest is EXCLUDED here — it stays on the IngestStatus surface.
const drugInFlight =
drugTagged.find(({ state }) => state === 'active') ??
drugTagged.find(({ state }) => state === 'waiting' || state === 'delayed') ??
drugTagged.find(({ state }) => state === 'failed')
const drugDownloads = drugInFlight
? [drugInFlight].map(({ job, state }) => {
const parsed = this.parseProgress(job.progress)
return {
jobId: DownloadDrugDataJob.jobId,
url: 'https://api.fda.gov/download.json',
progress: parsed.percent,
filepath: job.data.currentPartName || 'FDA Drug Reference',
filetype: 'drug-data',
title: 'FDA Drug Reference',
downloadedBytes: parsed.downloadedBytes,
totalBytes: parsed.totalBytes,
lastProgressTime: parsed.lastProgressTime,
status: state,
failedReason: job.failedReason || undefined,
}
})
: []
const allDownloads = [...fileDownloads, ...extractDownloads, ...modelDownloads, ...drugDownloads]
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
return filtered.sort((a, b) => {
@ -116,6 +151,7 @@ export class DownloadService {
RunDownloadJob.queue,
RunExtractPmtilesJob.queue,
DownloadModelJob.queue,
DownloadDrugDataJob.queue,
]) {
const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId)
@ -159,9 +195,42 @@ export class DownloadService {
return await this._cancelModelDownloadJob(jobId, modelJob, modelQueue)
}
// FDA drug dataset: cancel is matched on the deterministic jobId only (the
// single card the aggregator shows). The continuation parts run under
// auto-generated ids, so cancelling must stop the whole CHAIN, not just the
// current part — otherwise the next continuation fires after we remove one.
if (jobId === DownloadDrugDataJob.jobId) {
return await this._cancelDrugDownloadJob()
}
return { success: true, message: 'Job not found (may have already completed)' }
}
/**
* Cancel the FDA drug-data download.
*
* The drug job has no Redis cancel-signal / AbortController (unlike
* RunDownloadJob), and it self-continues into the next part under a fresh jobId.
* So a v1 cancel that only removed the current job would leave the next
* continuation to fire. Instead we obliterate the single-purpose drug-download
* queue (force = removes the active/locked job too), which drops the current
* part AND every queued continuation in one shot. Scoped to the drug-download
* queue only the ingest queue and everything else are untouched. The on-disk
* parts are intentionally LEFT in place: they're resumable, and a re-trigger
* picks up from the .tmp rather than re-downloading. (Tracked as the v1 choice
* for cancel depth full cross-process signal cancel is a follow-up.)
*/
private async _cancelDrugDownloadJob(): Promise<{ success: boolean; message: string }> {
const queue = this.queueService.getQueue(DownloadDrugDataJob.queue)
try {
await queue.obliterate({ force: true })
return { success: true, message: 'Drug data download cancelled' }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { success: false, message: `Could not cancel drug data download: ${msg}` }
}
}
private async _cancelExtractJob(
jobId: string,
job: Job<RunExtractPmtilesJobParams>,

View File

@ -0,0 +1,830 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { rm } from 'node:fs/promises'
import type { Job } from 'bullmq'
import { QueueService } from './queue_service.js'
import { DownloadDrugDataJob, STORAGE_BASE } from '#jobs/download_drug_data_job'
import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job'
/**
* Manifest resource id for the FDA drug dataset the `installed_resources`
* row's `resource_id`, matching the `medicine-standard` tier manifest entry.
* Single source of truth so the install write-back, the tier-status math, and
* uninstall all agree on the same id.
*/
export const DRUG_DATASET_RESOURCE_ID = 'openfda-drug-labels'
import {
normalizeDrugName,
parseDownloadState,
deriveIngestPhase,
resolveExpectedTotal,
resolveIngestRecordsShown,
summarizeJobError,
isExportDateNewer,
} from '../../util/drug_labels.js'
import KVStore from '#models/kv_store'
import { parseCompareIds, MAX_COMPARE } from '../../util/compare_ids.js'
import type {
DrugSearchResult,
DrugLabelDetail,
DrugIngestStatus,
DrugPhaseState,
DrugDownloadStatus,
DrugIngestPhaseStatus,
DrugInteractionEntry,
} from '../../types/drug_reference.js'
/**
* Drug Reference v1 service layer.
*
* Exposes search (collapsed by brand+generic), detail fetch, ingest trigger,
* and ingest status. Mirrors the shape of DownloadService/ZimService.
*/
export class DrugReferenceService {
/**
* Search for drug labels, collapsed by (brand_name, generic_name).
*
* Each distinct (brand_name, generic_name) pair returns ONE result a
* representative row id (MIN(id)) and a labelCount of how many set_ids
* collapsed into it. This is the locked UX decision.
*
* Scope:
* 'name' (default) existing path: MATCH(searchable_name) on brand+generic.
* 'indication' new path: MATCH(searchable_name, indications) on the combined
* ft_drug_labels_name_indications index so users can search by
* what a drug treats ("heartburn", "high blood pressure").
*
* Strategy (both scopes):
* 1. FULLTEXT path: MATCH(cols) AGAINST(? IN NATURAL LANGUAGE MODE)
* relevance-ranked, requires >= 3 chars (innodb_ft_min_token_size = 3).
* 2. LIKE fallback: query < 3 chars OR FULLTEXT throws LIKE '%term%'.
* 3. Both paths apply the optional product_type filter and GROUP BY collapse.
*/
async search(
query: string,
options: {
productType?: string
route?: string
sort?: 'relevance' | 'name'
limit?: number
offset?: number
scope?: 'name' | 'indication'
}
): Promise<DrugSearchResult[]> {
const limit = options.limit ?? 50
const offset = options.offset ?? 0
const scope = options.scope ?? 'name'
const normalized = normalizeDrugName(query, null) ?? query.trim()
if (!normalized || normalized.length === 0) return []
const useLike = normalized.length < 3
if (scope === 'indication') {
if (!useLike) {
try {
return await this.searchIndicationFulltext(normalized, options.productType, limit, offset)
} catch (err) {
logger.warn(
`[DrugReferenceService] FULLTEXT indication search failed, falling back to LIKE: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
return await this.searchIndicationLike(normalized, options.productType, limit, offset)
}
if (!useLike) {
// FULLTEXT path
try {
return await this.searchFulltext(normalized, options, limit, offset)
} catch (err) {
logger.warn(
`[DrugReferenceService] FULLTEXT search failed, falling back to LIKE: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
// LIKE fallback
return await this.searchLike(normalized, options, limit, offset)
}
private async searchFulltext(
normalized: string,
opts: { productType?: string; route?: string; sort?: 'relevance' | 'name' },
limit: number,
offset: number
): Promise<DrugSearchResult[]> {
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount,
MAX(MATCH(searchable_name) AGAINST(? IN NATURAL LANGUAGE MODE)) AS relevance
FROM drug_labels
WHERE MATCH(searchable_name) AGAINST(? IN NATURAL LANGUAGE MODE)
`
const bindings: unknown[] = [normalized, normalized]
if (opts.productType) {
sql += ' AND product_type = ?'
bindings.push(opts.productType)
}
if (opts.route) {
// `route` is a comma-joined uppercase list (e.g. "ORAL, TOPICAL").
sql += ' AND route LIKE ?'
bindings.push(`%${opts.route.toUpperCase()}%`)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY ${opts.sort === 'name' ? 'COALESCE(brand_name, generic_name) ASC' : 'relevance DESC'}
LIMIT ? OFFSET ?
`
bindings.push(limit, offset)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
private async searchLike(
normalized: string,
opts: { productType?: string; route?: string; sort?: 'relevance' | 'name' },
limit: number,
offset: number
): Promise<DrugSearchResult[]> {
const term = `%${normalized}%`
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount
FROM drug_labels
WHERE (searchable_name LIKE ? OR brand_name LIKE ?)
`
const bindings: unknown[] = [term, term]
if (opts.productType) {
sql += ' AND product_type = ?'
bindings.push(opts.productType)
}
if (opts.route) {
sql += ' AND route LIKE ?'
bindings.push(`%${opts.route.toUpperCase()}%`)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY brand_name ASC
LIMIT ? OFFSET ?
`
bindings.push(limit, offset)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
/**
* FULLTEXT indication-scope search.
*
* MATCHes over (searchable_name, indications) must exactly match the
* ft_drug_labels_name_indications index column list. The MAX(MATCH ) pattern
* is LOAD-BEARING: MySQL 8.0 ONLY_FULL_GROUP_BY rejects a bare MATCH() in
* SELECT when GROUP BY is in effect; wrapping in MAX() makes it an aggregate
* and satisfies the mode constraint.
*/
private async searchIndicationFulltext(
normalized: string,
productType: string | undefined,
limit: number,
offset: number
): Promise<DrugSearchResult[]> {
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount,
MAX(MATCH(searchable_name, indications) AGAINST(? IN NATURAL LANGUAGE MODE)) AS relevance
FROM drug_labels
WHERE MATCH(searchable_name, indications) AGAINST(? IN NATURAL LANGUAGE MODE)
`
const bindings: unknown[] = [normalized, normalized]
if (productType) {
sql += ' AND product_type = ?'
bindings.push(productType)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY relevance DESC
LIMIT ? OFFSET ?
`
bindings.push(limit, offset)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
/**
* LIKE indication-scope fallback (query < 3 chars or FULLTEXT unavailable).
*
* Searches searchable_name OR indications so short queries still return
* useful results without requiring the FULLTEXT index.
*/
private async searchIndicationLike(
normalized: string,
productType: string | undefined,
limit: number,
offset: number
): Promise<DrugSearchResult[]> {
const term = `%${normalized}%`
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount
FROM drug_labels
WHERE (searchable_name LIKE ? OR indications LIKE ?)
`
const bindings: unknown[] = [term, term]
if (productType) {
sql += ' AND product_type = ?'
bindings.push(productType)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY brand_name ASC
LIMIT ? OFFSET ?
`
bindings.push(limit, offset)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
private mapSearchRows(rows: any[]): DrugSearchResult[] {
if (!Array.isArray(rows)) return []
return rows.map((row) => ({
id: Number(row.id),
brand_name: row.brand_name ?? null,
generic_name: row.generic_name ?? null,
manufacturer: row.manufacturer ?? null,
route: row.route ?? null,
product_type: row.product_type ?? null,
labelCount: Number(row.labelCount ?? row.labelcount ?? 1),
}))
}
/**
* Load the full detail for a single drug label row by its surrogate id.
* Returns null if the row doesn't exist.
*/
async find(id: number): Promise<DrugLabelDetail | null> {
const { default: DrugLabel } = await import('#models/drug_label')
const row = await DrugLabel.find(id)
if (!row) return null
return {
id: row.id,
set_id: row.set_id,
spl_id: row.spl_id,
version: row.version,
brand_name: row.brand_name,
generic_name: row.generic_name,
manufacturer: row.manufacturer,
product_ndc: row.product_ndc,
route: row.route,
product_type: row.product_type,
indications: row.indications,
dosage: row.dosage,
warnings: row.warnings,
boxed_warning: row.boxed_warning,
drug_interactions: row.drug_interactions,
contraindications: row.contraindications,
when_using: row.when_using,
stop_use: row.stop_use,
source_updated_at: row.source_updated_at,
ingested_at: row.ingested_at.toISO() ?? '',
}
}
/**
* Return the drug interaction text for a set of label ids.
*
* - Dedupes and caps the id list via parseCompareIds / MAX_COMPARE.
* - One query: SELECT id, brand_name, generic_name, product_type,
* drug_interactions FROM drug_labels WHERE id IN (?).
* - Re-orders the rows to match the requested id order so the caller's
* column positions are stable even if MySQL returns rows in a different
* order. Missing ids (non-existent in the table) are silently omitted.
* - Returns [] for an empty or entirely-invalid id list.
*/
async getInteractionsFor(ids: number[]): Promise<DrugInteractionEntry[]> {
if (ids.length === 0) return []
// Dedupe + cap (caller may already have done this, but be defensive).
const safeIds = parseCompareIds(ids.join(',')).slice(0, MAX_COMPARE)
if (safeIds.length === 0) return []
const placeholders = safeIds.map(() => '?').join(', ')
const sql = `
SELECT id, brand_name, generic_name, product_type, drug_interactions
FROM drug_labels
WHERE id IN (${placeholders})
`
const rows = await db.rawQuery(sql, safeIds)
const rawRows: Array<{
id: number | string
brand_name: string | null
generic_name: string | null
product_type: string | null
drug_interactions: string | null
}> = Array.isArray(rows[0]) ? rows[0] : []
// Build a lookup by id so we can re-order to match the request order.
const byId = new Map<number, DrugInteractionEntry>()
for (const row of rawRows) {
const entry: DrugInteractionEntry = {
id: Number(row.id),
brand_name: row.brand_name ?? null,
generic_name: row.generic_name ?? null,
product_type: row.product_type ?? null,
drug_interactions: row.drug_interactions ?? null,
}
byId.set(entry.id, entry)
}
// Re-order: iterate safeIds, include only ids that exist in the table.
const ordered: DrugInteractionEntry[] = []
for (const id of safeIds) {
const entry = byId.get(id)
if (entry) ordered.push(entry)
}
return ordered
}
/**
* Get current row count what's searchable right now.
*/
async rowCount(): Promise<number> {
try {
const result = await db.rawQuery('SELECT COUNT(*) AS cnt FROM drug_labels')
const rows = result[0] as Array<{ cnt: number | string }>
return Number(rows[0]?.cnt ?? 0)
} catch {
return 0
}
}
/**
* Dispatch the download phase (idempotent deduped by deterministic jobId).
* Auto-chains the ingest phase on completion. Returns "already running" if the
* download job is active/waiting.
*/
async triggerDownload() {
return DownloadDrugDataJob.dispatch(true)
}
/**
* Freshness check for the content-auto-update path: compare the live openFDA
* manifest `export_date` against the last-ingested one (KV
* `drugReference.lastUpdatedExportDate`). Reuses the job's single manifest
* fetch (Maxim 4 one openFDA call site) and the pure `isExportDateNewer`
* compare (defensive about the unconfirmed date format; see its TODO).
*
* Returns `{ updateAvailable, latestExportDate, currentExportDate }`. Never
* triggers a download itself `attemptAutoUpdate` decides whether to, after
* its own gating (only when installed, no active job).
*/
async checkForUpdate(): Promise<{
updateAvailable: boolean
latestExportDate: string | null
currentExportDate: string | null
}> {
const current = await KVStore.getValue('drugReference.lastUpdatedExportDate')
const manifest = await DownloadDrugDataJob.fetchManifest()
const latest = manifest.export_date ?? null
return {
updateAvailable: isExportDateNewer(latest ?? '', current),
latestExportDate: latest,
currentExportDate: current,
}
}
/**
* Freshness pass for the drug dataset, invoked from
* `ContentAutoUpdateService.attemptDrugDataset()` inside the hourly content
* loop so the dataset refreshes alongside ZIMs and maps under the shared
* `contentAutoUpdate.*` master switch and window, not on a rogue schedule.
*
* Gating (unchanged from the prior standalone job): only acts when the dataset
* is installed, and never while a drug download or ingest is already in flight,
* so it can't stack a refresh on a running install. A failed manifest fetch is
* a transient miss (offline), reported rather than thrown.
*/
async attemptAutoUpdate(): Promise<{
started: boolean
reason: string
latestExportDate?: string | null
}> {
// Only act when the dataset is installed.
const rowCount = await this.rowCount()
if (rowCount === 0) {
return { started: false, reason: 'not-installed' }
}
// Don't stack on top of an in-flight download/ingest.
const [dlJob, ingJob] = await Promise.all([
DownloadDrugDataJob.getJob(),
IngestDrugDataJob.getJob(),
])
const activeStates = ['active', 'waiting', 'delayed']
const dlState = dlJob ? await dlJob.getState() : undefined
const ingState = ingJob ? await ingJob.getState() : undefined
if (
(dlState && activeStates.includes(dlState)) ||
(ingState && activeStates.includes(ingState))
) {
return { started: false, reason: 'job-in-flight' }
}
let check: Awaited<ReturnType<DrugReferenceService['checkForUpdate']>>
try {
check = await this.checkForUpdate()
} catch (err) {
// Offline or manifest fetch failed — transient, retried next run.
logger.warn(
`[DrugReferenceService] Freshness check failed (will retry next run): ${
err instanceof Error ? err.message : String(err)
}`
)
return { started: false, reason: 'check-failed' }
}
if (!check.updateAvailable) {
return {
started: false,
reason: `up-to-date (current=${check.currentExportDate ?? 'none'}, latest=${
check.latestExportDate ?? 'unknown'
})`,
latestExportDate: check.latestExportDate,
}
}
logger.info(
`[DrugReferenceService] Newer export_date available ` +
`(current=${check.currentExportDate ?? 'none'} → latest=${check.latestExportDate}); ` +
'triggering re-download.'
)
const result = await this.triggerDownload()
return {
started: result.created,
reason: result.created ? 'update-dispatched' : result.message,
latestExportDate: check.latestExportDate,
}
}
/**
* Dispatch the ingest phase from the already-downloaded on-disk parts (the
* manual "Ingest into search" path). Guards on the KV download-state marker so
* it fails fast with a typed result when nothing has been downloaded, rather
* than dispatching a job that would immediately fail in the worker.
*/
async triggerIngestFromDisk() {
const marker = parseDownloadState(await KVStore.getValue('drugReference.downloadState'))
if (!marker) {
return {
job: undefined,
created: false,
message: 'Nothing downloaded — run Download FDA data first.',
nothingDownloaded: true,
}
}
const result = await IngestDrugDataJob.dispatch()
return { ...result, nothingDownloaded: false }
}
/**
* Force-clear a wedged ingest and restart it from the on-disk parts.
*
* A worker killed mid-ingest (e.g. during a `nomad upgrade`) leaves its job
* 'active' holding a lock BullMQ won't reclaim until lockDuration elapses, so
* the normal dispatch refuses to start a new ingest and the UI button stays
* disabled ("Indexing…") with no way out. Obliterating the single-purpose
* drug-ingest queue removes the stuck job (force = even active/locked); we then
* re-dispatch from disk. The downloaded parts and the download-state marker are
* left untouched, so this restarts ingest WITHOUT re-downloading.
*/
async resetAndReingest() {
const marker = parseDownloadState(await KVStore.getValue('drugReference.downloadState'))
if (!marker) {
return {
job: undefined,
created: false,
message: 'Nothing downloaded — run Download FDA data first.',
nothingDownloaded: true,
}
}
const queue = QueueService.getInstance().getQueue(IngestDrugDataJob.queue)
try {
// force: true removes the active/locked stuck job too. Scoped to the
// single-purpose ingest queue, so nothing else is affected.
await queue.obliterate({ force: true })
logger.info('[DrugReferenceService] drug-ingest queue obliterated for restart')
} catch (err) {
logger.warn(
`[DrugReferenceService] ingest queue obliterate failed (continuing to dispatch): ${
err instanceof Error ? err.message : String(err)
}`
)
}
const result = await IngestDrugDataJob.dispatch()
return { ...result, nothingDownloaded: false }
}
/**
* Uninstall the offline FDA drug dataset the curated-tier "remove" path.
*
* Mirrors how ZimService.delete() reverses a ZIM install: stop anything that
* could re-create the data, delete the on-disk artifacts, drop the data, and
* remove the install-state row so the tier reads not-installed and the home
* tiles auto-hide (the same cascade as a ZIM delete).
*
* Order matters:
* 1. Obliterate BOTH drug queues (force = removes active/locked jobs too) so a
* running download/ingest can't write rows back in after we clear them.
* Scoped to the two single-purpose drug queues nothing else is touched.
* 2. Delete the on-disk parts dir (STORAGE_BASE is a FIXED constant, never a
* client value no path-traversal surface). Usually empty after a full
* ingest; non-empty only when uninstalling a downloaded-not-yet-ingested
* state.
* 3. TRUNCATE drug_labels (not DROP) preserves the schema + FULLTEXT
* indexes so a later reinstall re-ingests without a migration.
* 4. Clear the KV markers (download-state + last-updated export_date).
* 5. Delete the `installed_resources` 'dataset' row.
*
* Every step is best-effort and logged: a partial failure still removes as much
* as it can and reports what it did, rather than leaving a half-uninstalled
* state with no signal.
*/
async uninstall(): Promise<{
success: boolean
rowsDropped: number
message: string
}> {
const rowsBefore = await this.rowCount()
const errors: string[] = []
// 1. Stop in-flight jobs on both drug queues (force removes locked/active).
for (const queueName of [DownloadDrugDataJob.queue, IngestDrugDataJob.queue]) {
try {
await QueueService.getInstance().getQueue(queueName).obliterate({ force: true })
logger.info(`[DrugReferenceService] obliterated ${queueName} for uninstall`)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceService] obliterate ${queueName} failed: ${msg}`)
errors.push(`queue ${queueName}: ${msg}`)
}
}
// 2. Delete the on-disk parts directory. STORAGE_BASE is a fixed module const
// (never a client filename), so this is not a path-traversal surface.
try {
await rm(STORAGE_BASE, { recursive: true, force: true })
logger.info(`[DrugReferenceService] removed on-disk parts dir ${STORAGE_BASE}`)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceService] could not remove ${STORAGE_BASE}: ${msg}`)
errors.push(`storage: ${msg}`)
}
// 3. Drop the searchable data. TRUNCATE keeps schema + the FULLTEXT indexes
// so reinstall re-ingests cleanly with no migration.
try {
await db.rawQuery('TRUNCATE TABLE drug_labels')
logger.info('[DrugReferenceService] truncated drug_labels')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceService] TRUNCATE drug_labels failed: ${msg}`)
errors.push(`truncate: ${msg}`)
}
// 4. Clear KV markers.
try {
await KVStore.clearValue('drugReference.downloadState')
await KVStore.clearValue('drugReference.lastUpdatedExportDate')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceService] clearing KV markers failed: ${msg}`)
errors.push(`kv: ${msg}`)
}
// 5. Remove the install-state row so the tier reads not-installed.
try {
const { default: InstalledResource } = await import('#models/installed_resource')
await InstalledResource.query()
.where('resource_type', 'dataset')
.where('resource_id', DRUG_DATASET_RESOURCE_ID)
.delete()
logger.info('[DrugReferenceService] deleted installed_resources dataset row')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceService] deleting install row failed: ${msg}`)
errors.push(`install-row: ${msg}`)
}
const rowsDropped = rowsBefore - (await this.rowCount())
const success = errors.length === 0
return {
success,
rowsDropped,
message: success
? `Uninstalled FDA drug reference (${rowsDropped} labels removed).`
: `Uninstall completed with ${errors.length} issue(s): ${errors.join('; ')}`,
}
}
/**
* Resolve the canonical deterministic job for a phase's queue, falling back to
* the most-progressed auto-id continuation when the deterministic job is
* absent or completed (passes > 0 use auto-generated ids). Each phase has its
* own queue + jobId, so this is called once per queue with the matching ids.
*/
private async resolvePhaseJob(
queueName: string,
deterministicJobId: string
): Promise<Job | undefined> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(queueName)
let job = await queue.getJob(deterministicJobId)
if (!job || (await job.getState()) === 'completed') {
const activeJobs = await queue.getJobs(['active', 'waiting', 'delayed'])
const continuation = activeJobs
.filter((j) => j.id !== deterministicJobId)
.sort((a, b) => (b.data?.partIndex ?? 0) - (a.data?.partIndex ?? 0))[0]
if (continuation) {
job = continuation
} else {
// No live continuation. If the chain FAILED partway, surface the failed
// job so the status reads 'failed' instead of falsely 'ready' with only
// part 1's count. BUT removeOnFail keeps history: a STALE failure from a
// prior run must not mask a newer successful one (seen live: a fixed
// re-run completed, yet the panel stayed 'failed' on last night's
// duplicate-key job). Compare finish times and only surface the failed
// job when it is the most recent terminal outcome.
const [failedJobs, completedJobs] = await Promise.all([
queue.getJobs(['failed']),
queue.getJobs(['completed']),
])
const newestFailed = failedJobs.sort((a, b) => (b.finishedOn ?? 0) - (a.finishedOn ?? 0))[0]
const newestCompletedFinish = completedJobs.reduce(
(max, j) => Math.max(max, j.finishedOn ?? 0),
0
)
if (newestFailed && (newestFailed.finishedOn ?? 0) > newestCompletedFinish) {
job = newestFailed
}
}
}
return job
}
/** Map a BullMQ job state to the per-phase run state. */
private phaseStateFor(state: string | undefined): DrugPhaseState {
if (state === 'failed') return 'failed'
if (state === 'completed') return 'completed'
if (state === 'active' || state === 'waiting' || state === 'delayed') return 'running'
return 'idle'
}
/**
* Return the two-phase ingest status for the UI panel. Reads the download job
* (drug-download queue) and the ingest job (drug-ingest queue) independently,
* merges the KV download-state marker + last-updated marker + live row count,
* and derives the top-level phase from the two sub-phases.
*/
async getIngestStatus(): Promise<DrugIngestStatus> {
const [downloadJob, ingestJob] = await Promise.all([
this.resolvePhaseJob(DownloadDrugDataJob.queue, DownloadDrugDataJob.jobId),
this.resolvePhaseJob(IngestDrugDataJob.queue, IngestDrugDataJob.jobId),
])
const [lastUpdated, rawMarker, count] = await Promise.all([
KVStore.getValue('drugReference.lastUpdatedExportDate'),
KVStore.getValue('drugReference.downloadState'),
this.rowCount(),
])
const marker = parseDownloadState(rawMarker)
// ── Download sub-status ─────────────────────────────────────────────────
const dlState = downloadJob ? await downloadJob.getState() : undefined
const dlData = downloadJob?.data ?? {}
let downloadPhaseState = this.phaseStateFor(dlState)
// A finished download job is pruned (removeOnComplete) but the marker proves
// the parts are on disk — treat that as a completed download phase so the
// manual "Ingest into search" button stays available.
if (downloadPhaseState === 'idle' && marker) downloadPhaseState = 'completed'
const download: DrugDownloadStatus = {
state: downloadPhaseState,
partsDone: downloadPhaseState === 'completed' ? (marker?.totalParts ?? dlData.totalParts ?? 0) : (dlData.partIndex ?? 0),
totalParts: dlData.totalParts ?? marker?.totalParts ?? 0,
bytesDownloaded: dlData.bytesDownloaded,
currentPartName: dlData.currentPartName ?? null,
failedReason:
dlState === 'failed' ? summarizeJobError(downloadJob?.failedReason) : undefined,
}
// ── Ingest sub-status ───────────────────────────────────────────────────
const ingState = ingestJob ? await ingestJob.getState() : undefined
const ingData = ingestJob?.data ?? {}
let ingestPhaseState = this.phaseStateFor(ingState)
// A pruned-but-successful ingest leaves rows + the last-updated marker and
// clears the download marker; reflect that as a completed ingest phase.
if (ingestPhaseState === 'idle' && !marker && count > 0) ingestPhaseState = 'completed'
// total_records 0 means "unknown" (e.g. a manifest rebuilt from an older
// marker) — resolveExpectedTotal falls back to a parts estimate, then the
// live row count, so the counter/%/ETA never silently vanish.
const expectedTotal = resolveExpectedTotal(
ingData.manifest?.total_records,
ingData.totalParts,
count
)
const jobRecords = ingData.recordsIngested ?? 0
// While ingesting, the per-job recordsIngested lags across the per-part
// continuation handoff (continuations run under auto jobIds). Drive the shown
// count from max(jobRecords, live rowCount) so a first ingest tracks the table
// filling 0 → ~259k, while a re-ingest into a populated table still rides the
// per-job counter. Outside the running phase, trust the job's own total.
// Always reconcile against the live row count (the per-job recordsIngested can
// be a partial/stale total — a completed pass-0 job only counted part 1; a
// failed continuation stops mid-run). WHILE RUNNING, additionally subtract the
// run's start-row baseline so a re-ingest into a populated table shows THIS
// run's progress (0 → ~259k) instead of reading ~100% from second zero.
// Outside running, start=0 so 'ready'/'failed' reflect total searchable rows.
const records =
ingestPhaseState === 'running'
? resolveIngestRecordsShown(jobRecords, count, expectedTotal, ingData.startRowCount ?? 0)
: resolveIngestRecordsShown(jobRecords, count, expectedTotal)
const ingest: DrugIngestPhaseStatus = {
state: ingestPhaseState,
records,
expectedTotal,
partsDone: ingData.partIndex ?? 0,
totalParts: ingData.totalParts ?? marker?.totalParts ?? 0,
currentPartName: ingData.currentPartName ?? null,
failedReason: ingState === 'failed' ? summarizeJobError(ingestJob?.failedReason) : undefined,
}
const phase = deriveIngestPhase(download, ingest, count)
// The active phase drives elapsed/ETA: ingest start when ingesting, else the
// download start.
const startedAtMs =
phase === 'ingesting'
? (ingData.startedAt ?? null)
: phase === 'downloading'
? (dlData.startedAt ?? null)
: null
const error =
phase === 'failed' ? (ingest.failedReason ?? download.failedReason) : undefined
return {
phase,
download,
ingest,
startedAtMs,
lastUpdated: lastUpdated ?? null,
rowCount: count,
error,
}
}
}

View File

@ -27,6 +27,8 @@ import WikipediaSelection from '#models/wikipedia_selection'
import InstalledResource from '#models/installed_resource'
import CollectionManifest from '#models/collection_manifest'
import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { DrugReferenceService } from './drug_reference_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { CollectionManifestService } from './collection_manifest_service.js'
import { KiwixLibraryService } from './kiwix_library_service.js'
@ -253,8 +255,13 @@ export class ZimService {
const allResources = CollectionManifestService.resolveTierResources(tier, category.tiers)
// Filter out already installed
const installed = await InstalledResource.query().where('resource_type', 'zim')
// Filter out already installed. Includes 'dataset' rows (the FDA drug labels)
// alongside 'zim' so an installed dataset is filtered out here — without it,
// a re-select of an installed Medicine tier would re-dispatch the ~1.7 GB drug
// download every time (the dataset branch's own getIngestStatus guard is a
// second line of defence, but this keeps the filter symmetric with the row-
// driven tier-status math).
const installed = await InstalledResource.query().whereIn('resource_type', ['zim', 'dataset'])
const installedIds = new Set(installed.map((r) => r.resource_id))
const toDownload = allResources.filter((r) => !installedIds.has(r.id))
@ -263,6 +270,33 @@ export class ZimService {
const downloadFilenames: string[] = []
for (const resource of toDownload) {
// A `dataset` resource (e.g. the FDA drug labels) is DB-ingested, not a
// ZIM file — route it to the drug download+ingest pipeline instead of
// RunDownloadJob. The install-state row written on ingest 'ready' (below,
// via resourceMeta) is what the installed-filter above and the tier-status
// math key off; until that row exists the getIngestStatus guard prevents
// re-dispatching the ~1.7 GB download on every tier select.
if (resource.type === 'dataset') {
const drugReferenceService = new DrugReferenceService()
const status = await drugReferenceService.getIngestStatus()
if (status.phase === 'ready' || status.rowCount > 0) {
logger.info('[ZimService] Drug dataset already ingested, skipping dispatch.')
continue
}
// DownloadDrugDataJob.dispatch() is idempotent on its deterministic
// jobId — a concurrent in-flight download returns "already running"
// without re-adding, so this is safe to call repeatedly. The resourceMeta
// is threaded through download → ingest so the final ingest pass writes
// the `installed_resources` 'dataset' row, making the tier read installed.
await DownloadDrugDataJob.dispatch(true, {
resourceId: resource.id,
version: resource.version,
collectionRef: categorySlug,
})
logger.info('[ZimService] Dispatched drug data download for dataset resource.')
continue
}
const existingJob = await RunDownloadJob.getActiveByUrl(resource.url)
if (existingJob) {
logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)

View File

@ -0,0 +1,23 @@
import KVStore from '#models/kv_store'
/**
* Affirmative-content gate for the drug-reference feature (upstream #1040).
*
* The verbatim FDA label search and the conditionOTC matching are grounded in
* regulated label text and ship with the tier. The hand-authored self-care and
* herbal REMEDY content is guidance we author, so it stays gated off by default
* until a clinician has done the content pass; the maintainer flips this on in a
* follow-up once that's signed off.
*
* Independent of the tier install-state (a `medicine-standard` install alone does
* NOT enable remedies). Defaults off a missing/null KV value reads as false
* and there is deliberately no user-facing toggle, so un-reviewed medical
* guidance can't be self-enabled.
*
* Read at every HTTP boundary that could emit remedy data (the drug-reference
* page prop and the conditions show / drugs API), so no affirmative content is
* serialized to the client while the gate is closed.
*/
export async function affirmativeRemediesEnabled(): Promise<boolean> {
return (await KVStore.getValue('drugReference.remediesEnabled')) === true
}

View File

@ -0,0 +1,25 @@
import vine from '@vinejs/vine'
/**
* "When to use what" request validators.
*
* Mirrors the drug_reference validators: vine, minimal, typed at the edge.
*/
/**
* GET /api/conditions/drugs
*
* Resolve OTC drugs for either a curated condition (`slug`) or a free-text
* situation (`q`). Both are optional at the schema level; the controller
* requires exactly one and 400s otherwise, so the error message is specific
* ("provide slug or q") rather than a generic vine union failure.
*/
export const conditionDrugsValidator = vine.compile(
vine.object({
slug: vine.string().trim().minLength(1).maxLength(80).optional(),
q: vine.string().trim().minLength(1).maxLength(200).optional(),
limit: vine.number().min(1).max(200).optional(),
route: vine.string().trim().minLength(1).maxLength(40).optional(),
sort: vine.enum(['relevance', 'name']).optional(),
})
)

View File

@ -9,6 +9,9 @@ export const specResourceValidator = vine.object({
description: vine.string(),
url: vine.string().url(),
size_mb: vine.number().min(0).optional(),
// Resource-type discriminator (absent == 'zim'). Required here because VineJS
// strips unknown keys, which would silently drop the field on manifest fetch.
type: vine.enum(['zim', 'dataset']).optional(),
})
// ---- ZIM Categories spec (versioned) ----

View File

@ -0,0 +1,40 @@
import vine from '@vinejs/vine'
import { PRODUCT_TYPES } from '../../types/drug_reference.js'
/**
* Drug Reference v1 request validators.
*
* Mirrors the stl_library validators: vine, minimal, typed at the edge.
*/
const PRODUCT_TYPE_VALUES = Object.values(PRODUCT_TYPES) as [string, ...string[]]
/** GET /api/drug-reference/search */
export const searchDrugValidator = vine.compile(
vine.object({
q: vine.string().trim().minLength(1).maxLength(200),
product_type: vine.enum(PRODUCT_TYPE_VALUES).optional(),
// Administration-route filter (openFDA `route`, e.g. ORAL, TOPICAL). Matched
// with LIKE because the column holds a comma-joined list; the UI sends
// curated values, the cap just bounds free input.
route: vine.string().trim().minLength(2).maxLength(40).optional(),
sort: vine.enum(['relevance', 'name'] as const).optional(),
limit: vine.number().min(1).max(200).optional(),
offset: vine.number().min(0).optional(),
scope: vine.enum(['name', 'indication'] as const).optional(),
})
)
/**
* GET /api/drug-reference/interactions?ids=1,2,3
*
* Accepts `ids` as a comma-separated string. The controller parses the actual
* id values via parseCompareIds (dedupe, drop non-positive-int, cap at 5).
* Vine validates only that `ids` is a non-empty string; the pure helper does
* the real semantic validation so it can be unit-tested without a running app.
*/
export const interactionsValidator = vine.compile(
vine.object({
ids: vine.string().trim().minLength(1).maxLength(200).optional(),
})
)

View File

@ -12,6 +12,8 @@ import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { AutoUpdateJob } from '#jobs/auto_update_job'
import { AppAutoUpdateJob } from '#jobs/app_auto_update_job'
import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job'
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job'
export default class QueueWork extends BaseCommand {
static commandName = 'queue:work'
@ -51,6 +53,7 @@ export default class QueueWork extends BaseCommand {
// Create a worker for each queue
for (const queueName of queuesToProcess) {
const stall = this.getStallOptionsForQueue(queueName)
const worker = new Worker(
queueName,
async (job) => {
@ -65,7 +68,15 @@ export default class QueueWork extends BaseCommand {
{
connection: queueConfig.connection,
concurrency: this.getConcurrencyForQueue(queueName),
lockDuration: 300000,
// lockDuration/maxStalledCount are per-queue. Non-drug queues keep
// the existing default (300000, BullMQ's default maxStalledCount).
// The drug download/ingest queues are NEW per-queue overrides
// (1_800_000 / 3) — see getStallOptionsForQueue — not a change to
// the default applied to every other queue.
lockDuration: stall.lockDuration,
...(stall.maxStalledCount !== undefined
? { maxStalledCount: stall.maxStalledCount }
: {}),
autorun: true,
}
)
@ -172,6 +183,8 @@ export default class QueueWork extends BaseCommand {
handlers.set(AutoUpdateJob.key, new AutoUpdateJob())
handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob())
handlers.set(ContentAutoUpdateJob.key, new ContentAutoUpdateJob())
handlers.set(DownloadDrugDataJob.key, new DownloadDrugDataJob())
handlers.set(IngestDrugDataJob.key, new IngestDrugDataJob())
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
queues.set(RunExtractPmtilesJob.key, RunExtractPmtilesJob.queue)
@ -183,10 +196,35 @@ export default class QueueWork extends BaseCommand {
queues.set(AutoUpdateJob.key, AutoUpdateJob.queue)
queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue)
queues.set(ContentAutoUpdateJob.key, ContentAutoUpdateJob.queue)
queues.set(DownloadDrugDataJob.key, DownloadDrugDataJob.queue)
queues.set(IngestDrugDataJob.key, IngestDrugDataJob.queue)
return [handlers, queues]
}
/**
* Per-queue BullMQ stall-recovery options.
*
* Every queue except the two drug queues keeps the branch default
* (lockDuration 300000, and BullMQ's default maxStalledCount of 1 left
* unset). The drug download/ingest queues are the ONLY per-queue override:
* each part is a long single stream (a ~150 MB resumable HTTP pull, then an
* unzip + JSON-stream ingest at concurrency 1), so a longer lock plus a
* higher stalled tolerance keeps a transient lock-renewal miss from killing
* the continuation chain ("job stalled more than allowable limit").
*/
private getStallOptionsForQueue(
queueName: string
): { lockDuration: number; maxStalledCount?: number } {
if (
queueName === DownloadDrugDataJob.queue ||
queueName === IngestDrugDataJob.queue
) {
return { lockDuration: 1_800_000, maxStalledCount: 3 }
}
return { lockDuration: 300000 }
}
/**
* Get concurrency setting for a specific queue
* Can be customized per queue based on workload characteristics
@ -201,6 +239,12 @@ export default class QueueWork extends BaseCommand {
[RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results
[EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive
[CheckUpdateJob.queue]: 1, // No need to run more than one update check at a time
// Drug download: one part at a time — a ~150 MB resumable HTTP pull per
// part, no benefit to parallelism and easier on the storage volume.
[DownloadDrugDataJob.queue]: 1,
// Drug ingest: one heavy stream at a time — unzipping + parsing ~150 MB
// of JSON into batched DB inserts; serial keeps memory bounded.
[IngestDrugDataJob.queue]: 1,
default: 3,
}

View File

@ -0,0 +1,119 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Drug Reference v1 openFDA drug label catalog.
*
* ~259k rows of FDA drug labels (Rx + OTC), downloaded from the openFDA bulk
* export and streamed into MySQL in 500-row batches. The idempotent upsert key
* is `set_id` a stable GUID for a labeling across all revisions. Re-running
* the ingest refreshes existing rows in place; no manual purge needed.
*
* Section text columns use mediumtext (up to 16 MB) so no openFDA section is
* truncated. The `searchable_name` varchar(768) is indexed (idx_drug_labels_searchable_name).
* Under utf8mb4 that column is 768 × 4 = 3072 bytes, which is exactly the InnoDB
* index-prefix limit for the DYNAMIC / COMPRESSED row formats (DYNAMIC is the
* MySQL 8.0 default). It would overflow the 767-byte limit of the older
* REDUNDANT / COMPACT row formats, so this depends on the 8.0 default row format.
* If a deployment forces an older row format, drop the column to varchar(191)
* (191 × 4 = 764 767) to stay within the 767-byte budget.
*
* The FULLTEXT index is created in a guarded try/catch so a non-InnoDB engine
* or an older MySQL version that doesn't support FULLTEXT doesn't break the
* migration. The search service degrades gracefully to LIKE when FULLTEXT is
* unavailable.
*/
export default class extends BaseSchema {
protected tableName = 'drug_labels'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.bigIncrements('id').primary()
// Idempotent upsert key. UNIQUE enforced in DB so re-ingest never dupes.
table.string('set_id', 64).notNullable().unique('uniq_drug_labels_set_id')
// Per-revision GUID for provenance (not the upsert key).
table.string('spl_id', 64).nullable()
table.string('version', 16).nullable()
// Identity fields — sourced from openfda sub-object.
table.string('brand_name', 255).nullable()
table.string('generic_name', 512).nullable()
table.string('manufacturer', 512).nullable()
table.string('product_ndc', 255).nullable()
table.string('route', 255).nullable()
// OTC vs Rx discriminator. Expected: 'HUMAN OTC DRUG' | 'HUMAN PRESCRIPTION DRUG'.
// Plain varchar — not a native enum — to allow future product type additions
// without ALTER TABLE (stl_files convention).
table.string('product_type', 32).nullable()
// Normalized brand+generic blob — computed once at ingest, never on read.
// 768 chars × 4 bytes (utf8mb4) = 3072 = the InnoDB DYNAMIC/COMPRESSED
// index-prefix limit (see header note).
table.string('searchable_name', 768).nullable()
// Section text — mediumtext so even the longest FDA label bodies are stored
// in full (indications/dosage/warnings can be multiple pages of text).
table.specificType('indications', 'mediumtext').nullable()
table.specificType('dosage', 'mediumtext').nullable()
table.specificType('warnings', 'mediumtext').nullable()
table.specificType('boxed_warning', 'mediumtext').nullable()
table.specificType('drug_interactions', 'mediumtext').nullable()
table.specificType('contraindications', 'mediumtext').nullable()
// when_using / stop_use are OTC-specific and typically shorter.
table.text('when_using').nullable()
table.text('stop_use').nullable()
// Label version date, parsed from effective_time (YYYYMMDD → 'YYYY-MM-DD').
// Stored as a fixed-width varchar, not a DATE column, so it round-trips as a
// plain string: the model declares it string|null, but mysql2 hands back a
// JS Date for a DATE column. v1 never range-queries this field, and
// 'YYYY-MM-DD' sorts chronologically as text.
table.string('source_updated_at', 10).nullable()
// Set on every upsert pass — tracks when this row was last refreshed.
table.timestamp('ingested_at').notNullable()
// ── Non-FULLTEXT indexes ────────────────────────────────────────────────
// OTC vs Rx filter pill.
table.index('product_type', 'idx_drug_labels_product_type')
// LIKE fallback + alpha sort on the brand name column.
table.index('brand_name', 'idx_drug_labels_brand')
// LIKE fallback on the normalized search blob.
table.index('searchable_name', 'idx_drug_labels_searchable_name')
})
// ── FULLTEXT index — guarded so a non-InnoDB engine doesn't block migration ──
//
// MySQL 8.0 InnoDB supports FULLTEXT natively (confirmed: repo uses mysql:8.0).
// The guard ensures a future engine change or a fresh install on an engine
// without FULLTEXT doesn't brick the migration runner — the search service
// degrades to LIKE on a MATCH() failure, so an absent index is non-fatal.
//
// Only the name index ships in v1; search MATCHes searchable_name. A combined
// name+indications index (search-by-what-it-treats) is deferred: FULLTEXT
// can't take a prefix length, and indexing the full mediumtext body adds heavy
// index weight v1 doesn't use.
//
// Deferred so it runs AFTER createTable executes — Lucid's schema builder
// is deferred, so a bare ALTER here would hit a not-yet-created table. The
// this.defer(db => …) pattern (see 1775100000001_create_custom_library_sources_table.ts)
// queues it to run on the live connection once the table exists.
this.defer(async (db) => {
try {
await db.rawQuery(
`ALTER TABLE drug_labels ADD FULLTEXT INDEX ft_drug_labels_name (searchable_name)`
)
} catch {
// Non-InnoDB or FULLTEXT unsupported — search falls back to LIKE.
}
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,44 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Drug Reference v2 combined name+indications FULLTEXT index.
*
* Adds a FULLTEXT index over (searchable_name, indications) so users can
* search by what a drug treats ("heartburn", "high blood pressure") in
* addition to the existing name-only search path.
*
* Design notes:
* - FULLTEXT indexes cannot take a column prefix length, so the full
* mediumtext body of `indications` is indexed. On ~259k rows this adds
* meaningful index weight.
* - The guard mirrors the existing ft_drug_labels_name guard in migration
* 1778600000004: a non-InnoDB engine or a MySQL version without FULLTEXT
* support must not block the migration runner. The indication-search path
* degrades gracefully to a LIKE fallback when the index is absent.
* - The MATCH() column list in DrugReferenceService MUST be exactly
* (searchable_name, indications) matching this index or MySQL will
* refuse the query with "Can't find FULLTEXT index matching the column list".
*/
export default class extends BaseSchema {
async up() {
// ── Combined name+indications FULLTEXT index — guarded ──────────────────
try {
await this.db.rawQuery(
`ALTER TABLE drug_labels ADD FULLTEXT INDEX ft_drug_labels_name_indications (searchable_name, indications)`
)
} catch {
// Non-InnoDB or FULLTEXT unsupported — indication search falls back to LIKE.
}
}
async down() {
// ── Drop guarded — index may not exist if up() guard caught an error ────
try {
await this.db.rawQuery(
`ALTER TABLE drug_labels DROP INDEX ft_drug_labels_name_indications`
)
} catch {
// Index never existed — nothing to drop.
}
}
}

View File

@ -0,0 +1,16 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'drug_labels'
async up() {
await this.db.rawQuery(
'ALTER TABLE drug_labels MODIFY COLUMN ingested_at timestamp NOT NULL ' +
'DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
async down() {
await this.db.rawQuery('ALTER TABLE drug_labels MODIFY COLUMN ingested_at timestamp NOT NULL')
}
}

View File

@ -75,7 +75,8 @@ const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onC
)}
<span className="text-lime-400 text-sm ml-1">
{badgeTier.name}
{badgeStatus === 'downloading' && ' (downloading)'}
{badgeStatus === 'downloading' &&
(category.downloadingTierIndexing ? ' (indexing)' : ' (downloading)')}
</span>
</div>
) : (

View File

@ -0,0 +1,39 @@
import { IconAlertTriangle } from '@tabler/icons-react'
/**
* Co-located safety note for AFFIRMATIVE self-care / natural-remedy guidance.
*
* Distinct from {@link SafetyBanner}, which leads a page and frames FDA
* label-indication *matches*. This note sits at the head of every block that
* offers affirmative remedy guidance (the natural-remedy sections on the Drug
* Reference and "When to use what" pages), so the "informational only, not
* medical advice, seek real care in an emergency" framing appears WITH the
* guidance itself not only in a one-time banner the reader may have scrolled
* past. Same amber alert language as SafetyBanner so the two read as one system.
*/
export default function RemedySafetyNote() {
return (
<div
role="alert"
className="flex items-start gap-2.5 rounded-lg border-2 border-amber-400 bg-amber-50 px-3 py-2.5 text-xs text-amber-900"
>
<IconAlertTriangle
size={18}
className="mt-0.5 flex-shrink-0 text-amber-600"
aria-hidden="true"
/>
<div className="space-y-1">
<p className="font-bold">Informational only not medical advice.</p>
<p className="text-amber-800">
These remedies have limited or mixed evidence, are not FDA-evaluated, and are not a
substitute for professional care. Check with a clinician before using any of them, and
before combining one with a medication you already take.
</p>
<p className="text-amber-800">
In an emergency, or if symptoms are severe or worsening,{' '}
<strong>seek real medical care call emergency services</strong>.
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,40 @@
import { IconAlertTriangle } from '@tabler/icons-react'
/**
* "When to use what" top-of-page safety banner.
*
* A prominent amber callout that renders at the TOP of both the condition index
* and detail pages: results are FDA label-indication matches, NOT
* recommendations, NOT an FDA endorsement, and NOT a drug-interaction checker.
* It leads the page (not a footnote) so the caveat is read before any result.
*/
export default function SafetyBanner() {
return (
<div role="alert" className="mb-6 rounded-lg border-2 border-amber-400 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<IconAlertTriangle
size={22}
className="mt-0.5 flex-shrink-0 text-amber-600"
aria-hidden="true"
/>
<div className="text-sm text-amber-900">
<p className="font-bold mb-1">Informational reference only not medical advice.</p>
<ul className="list-disc pl-5 space-y-0.5 text-amber-800">
<li>
These results match FDA drug-label indications to a situation. They are{' '}
<strong>not a recommendation</strong> and <strong>not an FDA endorsement</strong>.
</li>
<li>
This is <strong>not a drug-interaction checker</strong>. Read each label&rsquo;s full
warnings, and check with a pharmacist or clinician before combining medicines.
</li>
<li>
In an emergency, or if symptoms are severe or worsening,{' '}
<strong>contact a medical professional or call emergency services</strong>.
</li>
</ul>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,64 @@
import { Link } from '@inertiajs/react'
import type { DrugSearchResult } from '../../../types/drug_reference'
import { PRODUCT_TYPES } from '../../../types/drug_reference'
interface Props {
result: DrugSearchResult
}
/**
* A single collapsed search result row.
*
* Shows brand name, generic name, OTC/Rx badge, route, and a "N labels"
* chip when more than one set_id collapsed into this result.
*/
export default function DrugResultRow({ result }: Props) {
const isRx = result.product_type === PRODUCT_TYPES.RX
const isOtc = result.product_type === PRODUCT_TYPES.OTC
return (
<Link
href={`/drug-reference/${result.id}`}
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors group"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm text-gray-900 group-hover:text-desert-green truncate">
{result.brand_name ?? result.generic_name ?? 'Unknown'}
</span>
{/* OTC / Rx badge */}
{isRx && (
<span className="px-1.5 py-0.5 rounded text-xs font-semibold bg-desert-orange/10 text-desert-orange-dark border border-desert-orange/30 flex-shrink-0">
Rx
</span>
)}
{isOtc && (
<span className="px-1.5 py-0.5 rounded text-xs font-semibold bg-desert-olive/10 text-desert-olive-dark border border-desert-olive/30 flex-shrink-0">
OTC
</span>
)}
{/* Collapsed labels count */}
{result.labelCount > 1 && (
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 flex-shrink-0">
{result.labelCount} labels
</span>
)}
</div>
<div className="flex flex-wrap gap-3 mt-0.5 text-xs text-gray-500">
{result.brand_name && result.generic_name && (
<span className="italic truncate">{result.generic_name}</span>
)}
{result.manufacturer && (
<span className="truncate">{result.manufacturer}</span>
)}
{result.route && <span>{result.route}</span>}
</div>
</div>
<span className="ml-3 text-gray-400 text-xs flex-shrink-0"></span>
</Link>
)
}

View File

@ -0,0 +1,277 @@
import { useEffect, useRef, useState } from 'react'
import type {
DrugIngestStatus,
DrugDownloadStatus,
DrugIngestPhaseStatus,
} from '../../../types/drug_reference'
interface Props {
status: DrugIngestStatus
/** Called to refresh status — typically polls /api/drug-reference/status */
onRefresh?: () => void
/** Poll interval in ms while running. Default 3000. */
pollIntervalMs?: number
}
/** Format a millisecond duration as "1h 04m", "6m 12s", or "12s". */
function fmtDuration(ms: number): string {
if (!isFinite(ms) || ms < 0) return '—'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const sec = totalSec % 60
if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`
if (m > 0) return `${m}m ${String(sec).padStart(2, '0')}s`
return `${sec}s`
}
/** Format a byte count as "128 MB" / "1.7 GB". */
function fmtBytes(bytes: number): string {
if (!isFinite(bytes) || bytes <= 0) return ''
const mb = bytes / (1024 * 1024)
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${Math.round(mb)} MB`
}
/** One phase block: headline, explainer, progress bar, optional timing row. */
function PhaseBlock({
title,
state,
pct,
explainer,
accentRunning,
counterLeft,
timing,
failedReason,
}: {
title: string
state: 'idle' | 'running' | 'completed' | 'failed'
pct: number
explainer: string
accentRunning: string
counterLeft?: React.ReactNode
timing?: React.ReactNode
failedReason?: string
}) {
const running = state === 'running'
const completed = state === 'completed'
const failed = state === 'failed'
const accent = failed
? 'text-red-700'
: completed
? 'text-green-700'
: running
? accentRunning
: 'text-gray-500'
const barColor = failed
? 'bg-red-500'
: completed
? 'bg-green-500'
: running
? accentRunning.replace('text-', 'bg-')
: 'bg-gray-300'
return (
<div className="space-y-1">
<div className="flex items-baseline justify-between gap-3">
<span className={`text-sm font-semibold ${accent}`}>
{title}
{running ? ' …' : ''}
</span>
{(running || completed) && (
<span className={`text-xs font-semibold ${accent} tabular-nums shrink-0`}>{pct}%</span>
)}
</div>
<p className="text-xs text-gray-500">{explainer}</p>
{counterLeft && (
<div className="text-xs text-gray-600 tabular-nums">{counterLeft}</div>
)}
{(running || completed || failed) && (
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${failed ? 100 : pct}%` }}
/>
</div>
)}
{timing}
{failed && failedReason && (
<p className="text-xs text-red-600 font-mono break-words bg-red-50 rounded px-2 py-1">
{failedReason}
</p>
)}
</div>
)
}
/** Download-phase explainer copy. */
function downloadExplainer(d: DrugDownloadStatus): string {
if (d.state === 'failed') {
return 'The download failed — it retries automatically; if it stays failed, press "Download FDA data" to restart. Finished parts are kept and resume where they left off.'
}
if (d.state === 'completed') {
return 'All parts are on disk. Indexing them into search next.'
}
if (d.state === 'running') {
return d.totalParts > 0
? `Pulling part ${d.partsDone + 1} of ${d.totalParts} — ~1.7 GB total across all parts.`
: 'Reading the openFDA download manifest…'
}
return 'Not started.'
}
/** Ingest-phase explainer copy. */
function ingestExplainer(i: DrugIngestPhaseStatus, rowCount: number): string {
if (i.state === 'failed') {
return 'Indexing failed — press "Ingest into search" to retry from the downloaded files (no re-download). Already-indexed labels are kept (the refresh is idempotent).'
}
if (i.state === 'completed') {
return `${rowCount.toLocaleString()} labels are now searchable offline.`
}
if (i.state === 'running') {
return i.totalParts > 0
? `Writing part ${i.partsDone + 1} of ${i.totalParts} into the offline database.`
: 'Writing labels into the offline database.'
}
return 'Waiting for downloaded data.'
}
/**
* Two-phase status panel for the Drug Reference download + ingest.
*
* Renders a download block (parts/bytes progress) and an ingest block (records
* "X of ~Nk labels" counter). Each block scopes its own failed/stalled copy.
* The top-level status.phase drives elapsed/ETA placement on the active phase.
* Auto-polls /status while either phase is running and ticks the clock between
* polls.
*/
export default function IngestStatus({ status, onRefresh, pollIntervalMs = 3000 }: Props) {
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [now, setNow] = useState<number>(() => Date.now())
const busy = status.phase === 'downloading' || status.phase === 'ingesting'
// Poll status while a phase is running.
useEffect(() => {
if (busy && onRefresh) {
pollRef.current = setInterval(onRefresh, pollIntervalMs)
} else if (pollRef.current) {
clearInterval(pollRef.current)
}
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [busy, onRefresh, pollIntervalMs])
// Tick a local clock every second while busy so elapsed/ETA advance smoothly.
useEffect(() => {
if (!busy) return
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [busy])
const { download, ingest } = status
// ── Download bar ─────────────────────────────────────────────────────────
const dlPct =
download.state === 'completed'
? 100
: download.totalParts > 0
? Math.min(100, Math.round((download.partsDone / download.totalParts) * 100))
: 0
const dlBytes = download.bytesDownloaded ? fmtBytes(download.bytesDownloaded) : ''
// ── Ingest bar (records-based) ───────────────────────────────────────────
const expected = ingest.expectedTotal > 0 ? ingest.expectedTotal : 0
const ingPct =
ingest.state === 'completed'
? 100
: expected > 0
? Math.min(100, Math.round((ingest.records / expected) * 100))
: ingest.totalParts > 0
? Math.min(100, Math.round((ingest.partsDone / ingest.totalParts) * 100))
: 0
// ── Timing (on the active phase only) ────────────────────────────────────
const elapsedMs = status.startedAtMs ? Math.max(0, now - status.startedAtMs) : null
const etaMs =
status.phase === 'ingesting' && elapsedMs !== null && expected > 0 && ingest.records > 2000
? (elapsedMs * (expected - ingest.records)) / ingest.records
: null
const downloadTiming =
status.phase === 'downloading' && elapsedMs !== null ? (
<div className="flex flex-wrap items-center gap-x-3 text-xs text-gray-500 tabular-nums">
<span>Elapsed {fmtDuration(elapsedMs)}</span>
{dlBytes && <span>{dlBytes} this part</span>}
</div>
) : null
const ingestTiming =
status.phase === 'ingesting' && elapsedMs !== null ? (
<div className="flex flex-wrap items-center gap-x-3 text-xs text-gray-500 tabular-nums">
<span>Elapsed {fmtDuration(elapsedMs)}</span>
{etaMs !== null && (
<span>
~{fmtDuration(etaMs)} left <span className="text-gray-400">(estimate)</span>
</span>
)}
</div>
) : null
return (
<div className="text-left space-y-4">
{/* Download phase */}
<PhaseBlock
title="Download FDA data"
state={download.state}
pct={dlPct}
explainer={downloadExplainer(download)}
accentRunning="text-blue-700"
timing={downloadTiming}
failedReason={download.failedReason}
/>
{/* Ingest phase */}
<PhaseBlock
title="Ingest into search"
state={ingest.state}
pct={ingPct}
explainer={ingestExplainer(ingest, status.rowCount)}
accentRunning="text-indigo-700"
counterLeft={
ingest.state === 'running' || ingest.state === 'completed' ? (
<span>
<span className="text-sm font-semibold text-gray-800">
{ingest.records.toLocaleString()}
</span>
{expected > 0 && (
<span className="text-gray-400"> of ~{expected.toLocaleString()} labels</span>
)}
</span>
) : undefined
}
timing={ingestTiming}
failedReason={ingest.failedReason}
/>
{/* Reassurance while busy */}
{busy && (
<p className="text-xs text-gray-400">
Runs in the background you can leave this page and it keeps going. Search turns on
automatically when ingest finishes.
</p>
)}
{/* Ready footer */}
{status.phase === 'ready' && status.lastUpdated && (
<p className="text-xs text-gray-500">FDA data version {status.lastUpdated}.</p>
)}
</div>
)
}

View File

@ -0,0 +1,70 @@
import type { DrugInteractionEntry } from '../../../types/drug_reference'
import { PRODUCT_TYPES } from '../../../types/drug_reference'
import LabelBlocks from './LabelBlocks'
interface Props {
entry: DrugInteractionEntry
onRemove: (id: number) => void
}
/**
* One column in the side-by-side drug interaction comparison view.
*
* Shows the drug identity (brand + generic name, OTC/Rx badge), then the
* drug_interactions text from the FDA label, or a muted "No labeled
* interaction text" note when the field is absent.
*/
export default function InteractionColumn({ entry, onRemove }: Props) {
const isRx = entry.product_type === PRODUCT_TYPES.RX
const isOtc = entry.product_type === PRODUCT_TYPES.OTC
const displayName = entry.brand_name ?? entry.generic_name ?? 'Unknown Drug'
return (
<div className="flex h-full flex-col min-w-0 border border-desert-tan-lighter rounded-lg overflow-hidden">
{/* Column header — fixed min-height so columns line up even when names wrap */}
<div className="flex min-h-[3.5rem] bg-desert-sand border-b border-desert-tan-lighter px-4 py-3">
<div className="flex w-full items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5 mb-0.5">
<span className="font-medium text-sm text-desert-green-dark break-words">
{displayName}
</span>
{isRx && (
<span className="px-1.5 py-0.5 rounded text-xs font-semibold bg-desert-orange text-desert-white flex-shrink-0">
Rx
</span>
)}
{isOtc && (
<span className="px-1.5 py-0.5 rounded text-xs font-semibold bg-desert-tan text-desert-white flex-shrink-0">
OTC
</span>
)}
</div>
{entry.brand_name && entry.generic_name && (
<p className="text-xs text-desert-stone italic truncate">{entry.generic_name}</p>
)}
</div>
<button
type="button"
onClick={() => onRemove(entry.id)}
aria-label={`Remove ${displayName} from comparison`}
className="flex-shrink-0 text-desert-stone-light hover:text-desert-stone-dark transition-colors text-lg leading-none ml-1 mt-0.5"
>
×
</button>
</div>
</div>
{/* Label text, parsed into readable blocks (FDA wording kept verbatim). */}
<div className="px-4 py-3 flex-1 space-y-3 text-desert-stone-dark">
{entry.drug_interactions ? (
<LabelBlocks text={entry.drug_interactions} />
) : (
<p className="text-sm text-desert-stone-light italic">
No labeled interaction text on this label.
</p>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,58 @@
import { parseLabelSection, isLabelSectionHeader, type LabelBlock } from '../../../util/drug_interactions'
/**
* Renders flattened FDA label section text as readable blocks: bullet lists, the
* muted section header, and paragraphs whose subsection number ("7.1") shows as a
* small badge. FDA wording is kept verbatim (see util/drug_interactions.ts); this
* only structures it. Shared by the interaction comparison view and the
* single-drug detail page.
*/
export default function LabelBlocks({
text,
tone = 'default',
}: {
text: string | null | undefined
tone?: 'default' | 'danger'
}) {
const blocks = parseLabelSection(text)
if (blocks.length === 0) return null
const bodyColor = tone === 'danger' ? 'text-red-800' : 'text-gray-800'
return (
<div className="space-y-3">
{blocks.map((block, i) => (
<LabelBlockView key={i} block={block} bodyColor={bodyColor} />
))}
</div>
)
}
function LabelBlockView({ block, bodyColor }: { block: LabelBlock; bodyColor: string }) {
if (block.bullets) {
return (
<ul className={`list-disc pl-5 space-y-1.5 text-sm ${bodyColor} leading-relaxed`}>
{block.bullets.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
)
}
if (isLabelSectionHeader(block.text)) {
return (
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
{block.text!.replace(/^\s*\d{1,2}\s+/, '')}
</p>
)
}
return (
<p className={`text-sm ${bodyColor} leading-relaxed`}>
{block.label && (
<span className="inline-block mr-1.5 px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 text-xs font-semibold align-baseline">
{block.label}
</span>
)}
{block.text}
</p>
)
}

View File

@ -0,0 +1,189 @@
import { Head, Link } from '@inertiajs/react'
import { IconArrowLeft, IconLeaf } from '@tabler/icons-react'
import AppLayout from '~/layouts/AppLayout'
import SafetyBanner from '~/components/conditions/SafetyBanner'
import RemedySafetyNote from '~/components/conditions/RemedySafetyNote'
import DrugResultRow from '~/components/drug-reference/DrugResultRow'
import type { ConditionSummary, NaturalRemedy } from '../../../types/conditions'
import { remedySourceName } from '../../../util/conditions'
import type { DrugSearchResult } from '../../../types/drug_reference'
interface PageProps {
condition: ConditionSummary | null
drugs: DrugSearchResult[]
remedies: NaturalRemedy[]
drugRowCount: number
}
/**
* "When to use what" condition detail page.
*
* Header (condition label + category) + a prominent SafetyBanner + the OTC-first
* list of drugs whose FDA label indications match this situation. Each row links
* to its existing Drug Reference detail page. The empty state distinguishes
* "no FDA data yet" (drugRowCount === 0 point to Drug Reference) from
* "data present, but nothing matched this situation".
*/
export default function ConditionsShow({ condition, drugs, remedies, drugRowCount }: PageProps) {
const label = condition?.label ?? 'Condition'
const noData = drugRowCount === 0
return (
<AppLayout>
<Head title={label} />
<div className="p-4 max-w-3xl mx-auto">
{/* Back nav */}
<div className="mb-4">
<Link
href="/drug-reference"
className="inline-flex items-center gap-1 text-sm text-desert-green hover:underline"
>
<IconArrowLeft size={16} />
Drug Reference
</Link>
</div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold">{label}</h1>
{condition?.category && (
<p className="text-sm text-gray-500 mt-0.5">{condition.category}</p>
)}
</div>
{/* Safety banner — hard ship requirement, top of content. */}
<SafetyBanner />
{/* Drug list / empty states */}
{noData ? (
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<p className="text-lg font-semibold mb-2">No drug data yet</p>
<p className="mb-6 opacity-70">
Download the offline FDA drug labels from Drug Reference to see matches for this
situation.
</p>
<Link href="/drug-reference">
<span className="inline-block rounded bg-desert-green px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-desert-green-dark">
Go to Drug Reference
</span>
</Link>
</div>
) : drugs.length === 0 ? (
<div className="text-center py-8 opacity-60">
No over-the-counter drugs match &ldquo;{label}&rdquo; in the current label data. Try
searching by drug name in{' '}
<Link href="/drug-reference" className="text-desert-green hover:underline">
Drug Reference
</Link>
.
</div>
) : (
<>
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-base font-semibold">Over-the-counter options</h2>
<span className="text-xs text-gray-500">
{drugs.length} result{drugs.length !== 1 ? 's' : ''}
</span>
</div>
<div className="divide-y divide-gray-200 border border-gray-200 rounded-lg overflow-hidden">
{drugs.map((d) => (
<DrugResultRow key={`${d.id}`} result={d} />
))}
</div>
</>
)}
{/* ── Natural remedies section (Phase 2) ───────────────────────────── */}
{remedies.length > 0 && (
<div className="mt-8">
<div className="flex items-center gap-2 mb-3">
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-desert-tan/20 text-desert-tan-dark">
<IconLeaf size={16} />
</span>
<h2 className="text-base font-semibold text-desert-tan-dark">Natural remedies</h2>
<span className="text-xs text-desert-stone ml-auto">
{remedies.length} {remedies.length !== 1 ? 'remedies' : 'remedy'}
</span>
</div>
{/* Safety note co-located with the affirmative remedy guidance,
not just the page-top banner (upstream PR #1040 requirement). */}
<div className="mb-3">
<RemedySafetyNote />
</div>
<div className="space-y-3">
{remedies.map((r) => (
<NaturalRemedyCard key={r.slug} remedy={r} />
))}
</div>
{/* Plain-text credit — no link-out; the reference is fully bundled. */}
<p className="mt-3 text-xs text-desert-stone">
Sources: NCCIH &ldquo;Herbs at a Glance&rdquo; (NIH) and US-government health
guidance (CDC, MedlinePlus/NLM, FDA). Public domain (US government works).
</p>
</div>
)}
{/* ── Source citation ───────────────────────────────────────────────── */}
<footer className="mt-8 pt-4 border-t border-gray-200 text-xs text-gray-500 space-y-1">
<p>
<strong>Source:</strong> U.S. Food &amp; Drug Administration drug labeling, via{' '}
<strong>openFDA</strong> public domain (CC0 1.0). NOMAD is not affiliated with or
endorsed by the FDA.
</p>
<p>
Matches are FDA label-indication text, not medical recommendations. Do not rely on this
data to make decisions regarding medical care.
</p>
</footer>
</div>
</AppLayout>
)
}
// ─── Natural remedy card ──────────────────────────────────────────────────────
function NaturalRemedyCard({ remedy }: { remedy: NaturalRemedy }) {
return (
<div className="rounded-lg border border-desert-tan-lighter/60 bg-desert-white overflow-hidden">
{/* Card header */}
<div className="flex items-start justify-between gap-2 bg-desert-tan/10 px-4 py-3 border-b border-desert-tan-lighter/40">
<div>
<p className="font-semibold text-sm text-desert-tan-dark">
{remedy.name}
<span className="ml-2 inline-block rounded-full bg-desert-tan/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-desert-tan-dark align-middle">
{remedy.kind === 'self-care' ? 'Self-care' : 'Herb'}
</span>
</p>
{remedy.commonNames.length > 0 && (
<p className="text-xs text-desert-stone mt-0.5">{remedy.commonNames.join(', ')}</p>
)}
</div>
{/* Plain-text attribution deliberately NOT a link. The card is fully
self-contained for offline use; nothing on it needs internet. */}
<span className="flex-shrink-0 text-xs text-desert-stone mt-0.5">
Source: {remedySourceName(remedy)}
</span>
</div>
{/* Card body */}
<div className="px-4 py-3 space-y-2 text-sm">
<p className="text-desert-green-darker">{remedy.uses}</p>
{remedy.how && (
<p className="text-xs text-desert-green-darker bg-desert-sand/40 rounded px-2 py-1.5 border border-desert-stone-lighter/40">
<strong>How:</strong> {remedy.how}
</p>
)}
<p className="text-xs text-desert-stone-dark border-l-2 border-desert-tan-lighter pl-2">
<strong className="text-desert-tan-dark">Evidence:</strong> {remedy.evidence}
</p>
<p className="text-xs text-desert-red-dark bg-desert-red/5 rounded px-2 py-1.5 border border-desert-red-lighter/30">
<strong>Cautions:</strong> {remedy.cautions}
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,920 @@
import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
import { Head, Link, router } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout'
import StyledButton from '~/components/StyledButton'
import DrugResultRow from '~/components/drug-reference/DrugResultRow'
import IngestStatus from '~/components/drug-reference/IngestStatus'
import SafetyBanner from '~/components/conditions/SafetyBanner'
import RemedySafetyNote from '~/components/conditions/RemedySafetyNote'
import { IconSearch, IconFirstAidKit, IconLeaf } from '@tabler/icons-react'
import type {
DrugSearchResult,
DrugIngestStatus,
} from '../../../types/drug_reference'
import type { ConditionSummary, ConditionDrugsResult, NaturalRemedy } from '../../../types/conditions'
import { remedySourceName } from '../../../util/conditions'
import { PRODUCT_TYPES } from '../../../types/drug_reference'
interface PageProps {
ingestStatus: DrugIngestStatus | null
rowCount: number
conditions: ConditionSummary[]
remedies: NaturalRemedy[]
/**
* Affirmative-content gate (#1040). False by default: the server sends no
* remedy data and the "Natural" filter is hidden, so only FDA label search and
* conditionOTC matching show. Flipped on after a clinician content-pass.
*/
remediesEnabled?: boolean
}
/**
* Sentinel for the type filter's "Natural" pill. Not an FDA product_type it
* routes the search to the curated NCCIH herb list instead of drug_labels.
*/
const NATURAL_FILTER = 'NATURAL'
/**
* Curated administration routes for the route filter the common openFDA
* `route` values a field user actually reaches for. The column holds a
* comma-joined uppercase list, so the backend matches with LIKE.
*/
const ROUTE_OPTIONS = [
'ORAL',
'TOPICAL',
'OPHTHALMIC',
'OTIC',
'NASAL',
'INHALATION',
'SUBLINGUAL',
'RECTAL',
'VAGINAL',
'TRANSDERMAL',
'DENTAL',
] as const
/** Title-case a route value for display (ORAL → Oral). */
function routeLabel(r: string): string {
return r.charAt(0) + r.slice(1).toLowerCase()
}
/** Case-insensitive remedy match on name / common names / uses. */
function matchRemedies(remedies: NaturalRemedy[], query: string): NaturalRemedy[] {
const q = query.trim().toLowerCase()
if (!q) return []
return remedies.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.commonNames.some((cn) => cn.toLowerCase().includes(q)) ||
r.uses.toLowerCase().includes(q)
)
}
const DEBOUNCE_MS = 350
const LIMIT = 50
/** Shared elevated-card surface for the result and detail panels. */
const CARD_SURFACE =
'rounded-2xl border border-desert-stone-lighter/60 bg-desert-white ' +
'shadow-[0_1px_2px_rgba(66,68,32,0.04),0_8px_24px_-12px_rgba(66,68,32,0.12)]'
/** Read an initial situation slug from ?situation= (reverse-link deep link). */
function initialSituationSlug(): string | null {
if (typeof window === 'undefined') return null
return new URLSearchParams(window.location.search).get('situation')
}
/**
* Match a free-text query to a curated situation by slug or label (case-insensitive:
* exact first, then "contains"). Returns the matched summary or null. The curated
* `searchTerms` are server-only, so off-list queries fall through to free-text on the
* API which still resolves a situation against the FULLTEXT index.
*/
function matchSituation(query: string, conditions: ConditionSummary[]): ConditionSummary | null {
const q = query.trim().toLowerCase()
if (!q) return null
const exact = conditions.find((c) => c.slug.toLowerCase() === q || c.label.toLowerCase() === q)
if (exact) return exact
return (
conditions.find(
(c) => c.label.toLowerCase().includes(q) || c.slug.replace(/-/g, ' ').includes(q)
) ?? null
)
}
/** Stable key for a collapsed drug result (brand+generic identity). */
function drugKey(d: DrugSearchResult): string {
return `${(d.brand_name ?? '').toLowerCase()}|${(d.generic_name ?? '').toLowerCase()}`
}
/**
* Unified Drug Reference surface.
*
* One search box runs BOTH directions of the symbiotic relationship:
* - drug-name search (a drug identity) "Drugs" section
* - situation matching (a situation its drugs) "For …" section
* Curated situation chips are always visible for browsing; clicking one searches it.
*
* Empty state (rowCount === 0): the "download FDA drug data" prompt + IngestStatus.
* Once data is loaded: chips + dual-section results, with the FDA-data update control
* and source citation at the foot.
*/
export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions, remedies = [], remediesEnabled = false }: PageProps) {
const [query, setQuery] = useState('')
const [productType, setProductType] = useState<string | null>(null)
const [route, setRoute] = useState<string | null>(null)
const [sort, setSort] = useState<'relevance' | 'name'>('relevance')
const [remedyKind, setRemedyKind] = useState<'all' | 'herb' | 'self-care'>('all')
// Drug-name results.
const [drugResults, setDrugResults] = useState<DrugSearchResult[]>([])
const [drugLoading, setDrugLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
// Situation results.
const [situation, setSituation] = useState<ConditionSummary | null>(null)
const [situationDrugs, setSituationDrugs] = useState<DrugSearchResult[]>([])
const [situationRemedies, setSituationRemedies] = useState<NaturalRemedy[]>([])
const [situationLoading, setSituationLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [error, setError] = useState<string | null>(null)
const [triggering, setTriggering] = useState(false)
const [ingesting, setIngesting] = useState(false)
const [resetting, setResetting] = useState(false)
const [status, setStatus] = useState<DrugIngestStatus | null>(ingestStatus)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Top-level phase derived from the two sub-phases. `busy` = a phase is running.
const phase = status?.phase ?? 'idle'
const busy = phase === 'downloading' || phase === 'ingesting'
// The manual "Ingest into search" button is available once parts are on disk.
const canIngestFromDisk =
status?.download.state === 'completed' && status?.ingest.state !== 'running'
// Group curated conditions by category, preserving curated order.
const grouped = useMemo(() => {
const map = new Map<string, ConditionSummary[]>()
for (const c of conditions) {
const bucket = map.get(c.category) ?? []
bucket.push(c)
map.set(c.category, bucket)
}
return Array.from(map.entries())
}, [conditions])
/** Drug-name search (one direction). Appends on "Load more". */
const searchDrugs = useCallback(
async (
q: string,
pt: string | null,
rt: string | null,
srt: 'relevance' | 'name',
off: number,
append: boolean
) => {
// The Natural pill routes the by-name direction to the curated herb list
// (client-side, see remedyMatches) — drug_labels isn't queried at all.
if (pt === NATURAL_FILTER || !q.trim()) {
setDrugResults([])
setHasMore(false)
return
}
setDrugLoading(true)
try {
const params = new URLSearchParams({ q, limit: String(LIMIT), offset: String(off) })
if (pt) params.set('product_type', pt)
if (rt) params.set('route', rt)
if (srt && srt !== 'relevance') params.set('sort', srt)
const resp = await fetch(`/api/drug-reference/search?${params}`)
if (!resp.ok) throw new Error(`Search failed: HTTP ${resp.status}`)
const json = (await resp.json()) as { results: DrugSearchResult[] }
const next = json.results ?? []
setDrugResults(append ? (prev) => [...prev, ...next] : next)
setHasMore(next.length === LIMIT)
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
} finally {
setDrugLoading(false)
}
},
[]
)
/**
* Situation search (the other direction). Resolves the query to a curated
* situation (by slug) or free text, fetches that situation's OTC drugs and
* natural remedies (Phase 2).
* Pass an explicit slug (chip / deep link) to force the curated path.
* opts.route and opts.sort are forwarded to the backend so the situation drug
* stack respects the active filter controls.
*/
const searchSituation = useCallback(
async (
q: string,
conds: ConditionSummary[],
forceSlug?: string,
opts?: { route: string | null; sort: 'relevance' | 'name' }
) => {
const matched = forceSlug
? conds.find((c) => c.slug === forceSlug) ?? null
: matchSituation(q, conds)
if (!q.trim() && !forceSlug) {
setSituation(null)
setSituationDrugs([])
setSituationRemedies([])
return
}
setSituationLoading(true)
try {
const params = new URLSearchParams()
if (matched) params.set('slug', matched.slug)
else params.set('q', q)
if (opts?.route) params.set('route', opts.route)
if (opts?.sort && opts.sort !== 'relevance') params.set('sort', opts.sort)
const resp = await fetch(`/api/conditions/drugs?${params}`)
if (!resp.ok) throw new Error(`Search failed: HTTP ${resp.status}`)
const json = (await resp.json()) as ConditionDrugsResult
setSituation(json.condition ?? matched ?? { slug: '', label: q.trim(), category: 'Search' })
setSituationDrugs(json.drugs ?? [])
setSituationRemedies(json.remedies ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
} finally {
setSituationLoading(false)
}
},
[]
)
/** Run both directions for a query. */
const runSearch = useCallback(
(q: string, pt: string | null, rt: string | null, srt: 'relevance' | 'name') => {
setError(null)
setOffset(0)
setSearched(q.trim().length > 0)
searchDrugs(q, pt, rt, srt, 0, false)
searchSituation(q, conditions, undefined, { route: rt, sort: srt })
},
[conditions, searchDrugs, searchSituation]
)
const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => runSearch(val, productType, route, sort), DEBOUNCE_MS)
}
const handleFilterChange = (pt: string | null) => {
setProductType(pt)
setOffset(0)
if (debounceRef.current) clearTimeout(debounceRef.current)
// Product-type filter only narrows the drug-name section.
searchDrugs(query, pt, route, sort, 0, false)
}
const handleRouteChange = (rt: string | null) => {
setRoute(rt)
setOffset(0)
if (debounceRef.current) clearTimeout(debounceRef.current)
searchDrugs(query, productType, rt, sort, 0, false)
// Re-fetch the active situation with the new route filter.
if (situation) {
const forceSlug = situation.slug || undefined
searchSituation(query, conditions, forceSlug, { route: rt, sort })
}
}
const handleSortChange = (srt: 'relevance' | 'name') => {
setSort(srt)
setOffset(0)
if (debounceRef.current) clearTimeout(debounceRef.current)
searchDrugs(query, productType, route, srt, 0, false)
// Re-fetch the active situation with the new sort order.
if (situation) {
const forceSlug = situation.slug || undefined
searchSituation(query, conditions, forceSlug, { route, sort: srt })
}
}
/** Click a chip (or follow a deep link): set the box and search that situation. */
const selectSituation = useCallback(
(c: ConditionSummary) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
setQuery(c.label)
setError(null)
setOffset(0)
setSearched(true)
searchDrugs(c.label, productType, route, sort, 0, false)
searchSituation(c.label, conditions, c.slug)
},
[conditions, productType, route, sort, searchDrugs, searchSituation]
)
// Honor a ?situation= deep link from the drug-detail reverse link, once.
useEffect(() => {
const slug = initialSituationSlug()
if (!slug) return
const target = conditions.find((c) => c.slug === slug)
if (target) selectSituation(target)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleLoadMore = () => {
const newOffset = offset + LIMIT
setOffset(newOffset)
searchDrugs(query, productType, route, sort, newOffset, true)
}
const refreshStatus = async () => {
const statusResp = await fetch('/api/drug-reference/status')
if (statusResp.ok) setStatus(await statusResp.json())
}
const handleTriggerDownload = async () => {
if (triggering) return
setTriggering(true)
try {
const resp = await fetch('/api/drug-reference/download', { method: 'POST' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
// ignore — status will update on next poll
} finally {
setTriggering(false)
}
}
const handleIngestFromDisk = async () => {
if (ingesting) return
setIngesting(true)
try {
const resp = await fetch('/api/drug-reference/ingest', { method: 'POST' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
// ignore — status will update on next poll
} finally {
setIngesting(false)
}
}
// Escape hatch for a wedged ingest: a worker killed mid-ingest (e.g. during an
// upgrade) can leave the job 'active' with a stale lock, which disables the
// normal buttons ("Indexing…") until lockDuration elapses. This force-clears
// that job and restarts ingest from the on-disk parts (no re-download).
const handleResetIngest = async () => {
if (resetting) return
if (
!window.confirm(
'Restart the ingest? This clears the current (possibly stuck) ingest job and ' +
're-runs it from the already-downloaded files.'
)
) {
return
}
setResetting(true)
try {
const resp = await fetch('/api/drug-reference/reset-ingest', { method: 'POST' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
// ignore — status will update on next poll
} finally {
setResetting(false)
}
}
const handleStatusRefresh = async () => {
try {
const resp = await fetch('/api/drug-reference/status')
if (resp.ok) {
const newStatus = (await resp.json()) as DrugIngestStatus
setStatus(newStatus)
if (newStatus.phase === 'ready' && newStatus.rowCount > rowCount) {
router.reload({ only: ['rowCount', 'ingestStatus'] })
}
}
} catch {
// ignore
}
}
const isEmpty = rowCount === 0
const hasQuery = query.trim().length > 0
const loading = drugLoading || situationLoading
// Dedupe: a drug shown in the situation section is suppressed from the drug-name
// section so the same product never appears twice on one screen.
const situationKeys = useMemo(
() => new Set(situationDrugs.map(drugKey)),
[situationDrugs]
)
const dedupedDrugResults = useMemo(
() => drugResults.filter((d) => !situationKeys.has(drugKey(d))),
[drugResults, situationKeys]
)
// Remedy matches for the by-name direction. With the Natural pill active an
// empty box browses the whole curated list; with a query it narrows by
// name/common-names/uses. Under Rx/OTC the user asked for drugs specifically,
// so the remedy block stays out of the way.
const remedyMatches = useMemo(() => {
let base: NaturalRemedy[]
if (productType === NATURAL_FILTER) {
base = query.trim() ? matchRemedies(remedies, query) : remedies
} else if (productType === null) {
base = matchRemedies(remedies, query)
} else {
return []
}
if (remedyKind === 'all') return base
return base.filter((r) => (r.kind ?? 'herb') === remedyKind)
}, [remedies, query, productType, remedyKind])
// When the Natural pill is active, hide the OTC drugs sub-section in the
// situation card (user asked for herbs/self-care only).
const visibleSituationDrugs = productType === NATURAL_FILTER ? [] : situationDrugs
// When a specific remedyKind filter is active, narrow the situation remedies too.
// Show remedies only when Natural pill (NATURAL_FILTER) or All-types (null).
const visibleSituationRemedies = useMemo(() => {
if (productType !== null && productType !== NATURAL_FILTER) return []
if (remedyKind === 'all') return situationRemedies
return situationRemedies.filter((r) => (r.kind ?? 'herb') === remedyKind)
}, [situationRemedies, productType, remedyKind])
const showSituationSection =
situation !== null && (visibleSituationDrugs.length > 0 || visibleSituationRemedies.length > 0)
const showDrugSection = dedupedDrugResults.length > 0
const showRemedySection = remedyMatches.length > 0
const nothingFound =
searched && !loading && !showSituationSection && !showDrugSection && !showRemedySection
return (
<AppLayout>
<Head title="Drug Reference" />
<div className="p-4 max-w-4xl mx-auto">
{/* Header */}
<div className="mb-4">
<div className="flex flex-wrap items-start justify-between gap-3 mb-1">
<h1 className="text-2xl font-bold text-desert-green-darker">Drug Reference</h1>
{rowCount > 0 && (
<Link href="/drug-reference/interactions">
<StyledButton variant="outline" size="sm" onClick={() => {}}>
Compare interactions
</StyledButton>
</Link>
)}
</div>
<p className="text-sm opacity-70">
Search a drug by name, or a situation burn, fever, diarrhea to see the
over-the-counter drugs whose offline FDA labels treat it.{' '}
{rowCount > 0 ? `${rowCount.toLocaleString()} labels.` : ''}
</p>
</div>
{isEmpty ? (
// ── Empty state ────────────────────────────────────────────────────
<div className="border-2 border-dashed border-desert-stone-lighter rounded-2xl p-8 text-center bg-desert-white">
<p className="text-lg font-semibold mb-2 text-desert-green-darker">No FDA drug data yet</p>
<p className="mb-6 opacity-70">
Download the openFDA drug-label dataset to enable offline search. Requires ~1.7 GB
compressed download (~810 GB after ingestion).
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<StyledButton
variant="primary"
onClick={handleTriggerDownload}
disabled={triggering || busy}
>
{phase === 'downloading'
? 'Downloading…'
: phase === 'ingesting'
? 'Indexing…'
: triggering
? 'Starting…'
: 'Download FDA drug data'}
</StyledButton>
{canIngestFromDisk && (
<StyledButton
variant="secondary"
onClick={handleIngestFromDisk}
disabled={ingesting || busy}
>
{ingesting ? 'Starting…' : 'Ingest into search'}
</StyledButton>
)}
{/* Escape hatch only while ingest appears to be running. Clears a
wedged "Indexing…" state (stale active job from a killed worker)
and restarts from the already-downloaded files. */}
{phase === 'ingesting' && (
<StyledButton
variant="outline"
onClick={handleResetIngest}
disabled={resetting}
>
{resetting ? 'Restarting…' : 'Restart ingest'}
</StyledButton>
)}
</div>
{status && (
<div className="mt-6">
<IngestStatus status={status} onRefresh={handleStatusRefresh} />
</div>
)}
</div>
) : (
// ── Unified search surface ─────────────────────────────────────────
<>
{/* Search box */}
<div className="relative mb-3">
<IconSearch
size={18}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-desert-stone"
/>
<input
type="text"
value={query}
onChange={handleQueryChange}
placeholder="Search a drug or a situation — ibuprofen, heartburn, poison ivy…"
className="w-full rounded-lg border border-desert-stone-lighter bg-white py-2 pl-10 pr-4 text-sm text-desert-green-darker transition focus:border-desert-green focus:outline-none focus:ring-2 focus:ring-desert-green/20"
/>
</div>
{/* OTC / Rx filter pills — narrow the by-name drug section */}
<div className="flex flex-wrap gap-2 mb-6">
<FilterPill active={productType === null} onClick={() => handleFilterChange(null)}>
All
</FilterPill>
<FilterPill
active={productType === PRODUCT_TYPES.OTC}
tone="olive"
onClick={() => handleFilterChange(PRODUCT_TYPES.OTC)}
>
OTC
</FilterPill>
<FilterPill
active={productType === PRODUCT_TYPES.RX}
tone="orange"
onClick={() => handleFilterChange(PRODUCT_TYPES.RX)}
>
Rx
</FilterPill>
{/* Affirmative-content gate (#1040): the "Natural" remedy filter
only appears once remedies are enabled (post clinician-pass). */}
{remediesEnabled && (
<FilterPill
active={productType === NATURAL_FILTER}
tone="olive"
onClick={() => handleFilterChange(NATURAL_FILTER)}
>
Natural
</FilterPill>
)}
{/* Secondary controls: route + sort for the drug-name search, or
herb/self-care sub-filter when Natural is active. */}
{productType === NATURAL_FILTER ? (
<div className="flex items-center gap-2 ml-auto">
{(['all', 'herb', 'self-care'] as const).map((k) => (
<FilterPill key={k} active={remedyKind === k} onClick={() => setRemedyKind(k)}>
{k === 'all' ? 'All kinds' : k === 'herb' ? 'Herbs' : 'Self-care'}
</FilterPill>
))}
</div>
) : (
<div className="flex items-center gap-2 ml-auto">
<select
value={route ?? ''}
onChange={(e) => handleRouteChange(e.target.value || null)}
className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
aria-label="Filter by administration route"
>
<option value="">Any route</option>
{ROUTE_OPTIONS.map((r) => (
<option key={r} value={r}>
{routeLabel(r)}
</option>
))}
</select>
<select
value={sort}
onChange={(e) => handleSortChange(e.target.value as 'relevance' | 'name')}
className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
aria-label="Sort drug results"
>
<option value="relevance">Best match</option>
<option value="name">AZ</option>
</select>
</div>
)}
</div>
{error && (
<div className="mb-4 rounded-lg border border-desert-red/30 bg-desert-red/5 p-3 text-sm text-desert-red-dark">
{error}
</div>
)}
{loading && !showSituationSection && !showDrugSection && (
<div className="text-center py-8 opacity-60">Searching</div>
)}
{/* ── Situation section (a situation → its drugs + remedies) ──────── */}
{showSituationSection && (
<section className={`${CARD_SURFACE} mb-6 overflow-hidden`}>
{/* Section header */}
<div className="flex items-center gap-2.5 border-b border-desert-stone-lighter/40 bg-desert-sand/40 px-4 py-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-desert-olive/10 text-desert-olive-dark">
<IconFirstAidKit size={18} />
</span>
<h2 className="text-sm font-bold text-desert-green-darker">
For{' '}
<span className="text-desert-olive-dark">
&ldquo;{situation?.label}&rdquo;
</span>
</h2>
<span className="ml-auto text-xs text-desert-stone">
{visibleSituationDrugs.length > 0 && `${visibleSituationDrugs.length} OTC`}
{visibleSituationDrugs.length > 0 && visibleSituationRemedies.length > 0 && ' · '}
{visibleSituationRemedies.length > 0 && `${visibleSituationRemedies.length} natural`}
</span>
</div>
{/* OTC drugs sub-section */}
{visibleSituationDrugs.length > 0 && (
<>
{visibleSituationRemedies.length > 0 && (
<div className="px-4 py-2 bg-desert-white border-b border-desert-stone-lighter/30">
<span className="text-xs font-semibold text-desert-stone uppercase tracking-wide">
Over-the-counter options
</span>
</div>
)}
<div className="divide-y divide-desert-stone-lighter/40">
{visibleSituationDrugs.map((d) => (
<DrugResultRow key={`sit-${d.id}`} result={d} />
))}
</div>
</>
)}
{/* Natural remedies sub-section */}
{visibleSituationRemedies.length > 0 && (
<div className="border-t border-desert-tan-lighter/40 bg-desert-sand/20">
<div className="flex items-center gap-2 px-4 py-2 border-b border-desert-tan-lighter/30">
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-desert-tan-dark">
<IconLeaf size={14} />
</span>
<span className="text-xs font-semibold text-desert-tan-dark uppercase tracking-wide">
Natural remedies
</span>
</div>
<div className="border-b border-desert-tan-lighter/20 p-3">
<RemedySafetyNote />
</div>
<div className="divide-y divide-desert-tan-lighter/30">
{visibleSituationRemedies.map((r) => (
<SituationRemedyRow key={r.slug} remedy={r} />
))}
</div>
</div>
)}
</section>
)}
{/* ── Drug-name section (a drug → identity) ─────────────────────── */}
{showDrugSection && (
<section className={`${CARD_SURFACE} mb-6 overflow-hidden`}>
<div className="flex items-center gap-2.5 border-b border-desert-stone-lighter/40 bg-desert-sand/40 px-4 py-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-desert-green/10 text-desert-green">
<IconSearch size={18} />
</span>
<h2 className="text-sm font-bold text-desert-green-darker">Drugs</h2>
<span className="ml-auto text-xs text-desert-stone">
{dedupedDrugResults.length} match
{dedupedDrugResults.length !== 1 ? 'es' : ''}
</span>
</div>
<div className="divide-y divide-desert-stone-lighter/40">
{dedupedDrugResults.map((d) => (
<DrugResultRow key={`drug-${d.id}`} result={d} />
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-desert-stone-lighter/40 p-3">
<StyledButton variant="secondary" onClick={handleLoadMore} disabled={drugLoading}>
{drugLoading ? 'Loading…' : 'Load more'}
</StyledButton>
</div>
)}
</section>
)}
{/* ── Natural remedies by name (or full browse on the Natural pill) ── */}
{showRemedySection && (
<section className={`${CARD_SURFACE} mb-6 overflow-hidden`}>
<div className="flex items-center gap-2.5 border-b border-desert-tan-lighter/40 bg-desert-sand/40 px-4 py-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-desert-tan/10 text-desert-tan-dark">
<IconLeaf size={18} />
</span>
<h2 className="text-sm font-bold text-desert-green-darker">Natural remedies</h2>
<span className="ml-auto text-xs text-desert-stone">
{remedyMatches.length} match{remedyMatches.length !== 1 ? 'es' : ''}
</span>
</div>
<div className="border-b border-desert-tan-lighter/20 p-3">
<RemedySafetyNote />
</div>
<div className="divide-y divide-desert-tan-lighter/30">
{remedyMatches.map((r) => (
<SituationRemedyRow key={`name-${r.slug}`} remedy={r} />
))}
</div>
</section>
)}
{nothingFound && (
<div className="text-center py-8 opacity-60">
No drugs, remedies, or situations match &ldquo;{query}&rdquo;. Try a situation below.
</div>
)}
{/* ── Browse: curated situation chips (always visible) ──────────── */}
<section className={`${CARD_SURFACE} overflow-hidden`}>
<div className="border-b border-desert-stone-lighter/40 bg-gradient-to-b from-desert-sand/50 to-transparent px-5 py-4">
<div className="flex items-center gap-2.5">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-desert-olive/10 text-desert-olive-dark">
<IconFirstAidKit size={18} />
</span>
<h2 className="text-sm font-bold text-desert-green-darker">Browse by situation</h2>
</div>
<p className="mt-1.5 text-xs text-desert-stone">
{hasQuery
? 'Or pick another situation to see its over-the-counter options.'
: 'Pick a situation to see the over-the-counter drugs whose FDA labels list it.'}
</p>
</div>
<div className="space-y-5 p-5">
<SafetyBanner />
{grouped.map(([category, items]) => (
<div key={category}>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-desert-tan-dark">
{category}
</h3>
<div className="flex flex-wrap gap-2">
{items.map((c) => {
const active = situation?.slug === c.slug
return (
<button
key={c.slug}
type="button"
onClick={() => selectSituation(c)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
active
? 'border-desert-olive bg-desert-olive text-white'
: 'border-desert-stone-lighter bg-white text-desert-green-darker hover:border-desert-olive hover:bg-desert-olive/5'
}`}
>
{c.label}
</button>
)
})}
</div>
</div>
))}
</div>
</section>
{/* ── FDA data update control ───────────────────────────────────── */}
<div className="mt-8 pt-6 border-t border-desert-stone-lighter/40">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<h3 className="text-sm font-semibold text-desert-green-darker">FDA data</h3>
<div className="flex flex-wrap items-center gap-2">
{canIngestFromDisk && (
<StyledButton
variant="outline"
size="sm"
onClick={handleIngestFromDisk}
disabled={ingesting || busy}
>
{ingesting ? 'Starting…' : 'Ingest into search'}
</StyledButton>
)}
<StyledButton
variant="secondary"
onClick={handleTriggerDownload}
disabled={triggering || busy}
>
{phase === 'downloading'
? 'Downloading…'
: phase === 'ingesting'
? 'Indexing…'
: 'Update FDA data'}
</StyledButton>
</div>
</div>
{status && <IngestStatus status={status} onRefresh={handleStatusRefresh} />}
</div>
</>
)}
{/* ── Source citation (CC0, no-endorsement) ───────────────────────── */}
<footer className="mt-8 pt-4 border-t border-desert-stone-lighter/40 text-xs text-desert-stone">
<strong>Source:</strong> U.S. Food &amp; Drug Administration drug labeling, via{' '}
<strong>openFDA</strong> public domain (CC0 1.0). NOMAD is not affiliated with or
endorsed by the FDA. Label data and situation matches are label-text only; do not rely on
them for medical decisions.
</footer>
</div>
</AppLayout>
)
}
// ─── Situation remedy row (compact, inside the situation card) ────────────────
function SituationRemedyRow({ remedy }: { remedy: NaturalRemedy }) {
return (
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-desert-tan-dark">
{remedy.name}
<span className="ml-2 inline-block rounded-full bg-desert-tan/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-desert-tan-dark align-middle">
{remedy.kind === 'self-care' ? 'Self-care' : 'Herb'}
</span>
</p>
{remedy.commonNames.length > 0 && (
<p className="text-xs text-desert-stone">{remedy.commonNames.join(', ')}</p>
)}
</div>
{/* Plain-text attribution deliberately NOT a link. The card is fully
self-contained for offline use; nothing on it needs internet. */}
<span className="flex-shrink-0 text-xs text-desert-stone mt-0.5">
Source: {remedySourceName(remedy)}
</span>
</div>
<p className="mt-1.5 text-xs text-desert-green-darker">{remedy.uses}</p>
{remedy.how && (
<p className="mt-1 text-xs text-desert-green-darker">
<strong>How:</strong> {remedy.how}
</p>
)}
{remedy.cautions && (
<p className="mt-1 text-xs text-desert-red-dark">
<strong>Cautions:</strong> {remedy.cautions}
</p>
)}
</div>
)
}
// ─── Filter pill ──────────────────────────────────────────────────────────────
function FilterPill({
active,
tone = 'green',
onClick,
children,
}: {
active: boolean
tone?: 'green' | 'olive' | 'orange'
onClick: () => void
children: React.ReactNode
}) {
const activeClass =
tone === 'orange'
? 'bg-desert-orange text-white border-desert-orange'
: tone === 'olive'
? 'bg-desert-olive text-white border-desert-olive'
: 'bg-desert-green text-white border-desert-green'
const hoverClass =
tone === 'orange'
? 'hover:border-desert-orange'
: tone === 'olive'
? 'hover:border-desert-olive'
: 'hover:border-desert-green'
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
active
? activeClass
: `border-desert-stone-lighter bg-white text-desert-green-darker ${hoverClass}`
}`}
>
{children}
</button>
)
}

View File

@ -0,0 +1,331 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { Head, Link } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout'
import StyledButton from '~/components/StyledButton'
import InteractionColumn from '~/components/drug-reference/InteractionColumn'
import IngestStatus from '~/components/drug-reference/IngestStatus'
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react'
import type { DrugSearchResult, DrugIngestStatus, DrugInteractionEntry } from '../../../types/drug_reference'
import { MAX_COMPARE, parseCompareIds } from '../../../util/compare_ids'
interface PageProps {
ingestStatus: DrugIngestStatus | null
rowCount: number
}
const DEBOUNCE_MS = 350
/**
* Drug Reference side-by-side interaction comparison page.
*
* Selection is URL-driven (?ids=1,2,3) so the view is shareable/bookmarkable.
* The drug picker reuses the /api/drug-reference/search call + DrugResultRow.
* Each selected drug renders as one InteractionColumn with removable header.
*
* This is NOT a pairwise checker it surfaces each drug's own FDA-labeled
* drug_interactions text side-by-side. A prominent amber disclaimer makes
* this clear at all times.
*/
export default function DrugReferenceInteractions({ ingestStatus, rowCount }: PageProps) {
// ── Selection state (URL-driven) ───────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<number[]>(() => {
if (typeof window === 'undefined') return []
const raw = new URLSearchParams(window.location.search).get('ids') ?? ''
return parseCompareIds(raw)
})
// ── Picker state ───────────────────────────────────────────────────────────
const [query, setQuery] = useState('')
const [pickerResults, setPickerResults] = useState<DrugSearchResult[]>([])
const [pickerLoading, setPickerLoading] = useState(false)
const [pickerError, setPickerError] = useState<string | null>(null)
const [pickerSearched, setPickerSearched] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// ── Comparison data ────────────────────────────────────────────────────────
const [entries, setEntries] = useState<DrugInteractionEntry[]>([])
const [loadingEntries, setLoadingEntries] = useState(false)
// ── Sync URL when selection changes ───────────────────────────────────────
useEffect(() => {
const url = new URL(window.location.href)
if (selectedIds.length > 0) {
url.searchParams.set('ids', selectedIds.join(','))
} else {
url.searchParams.delete('ids')
}
window.history.replaceState({}, '', url.toString())
}, [selectedIds])
// ── Load entries when selection changes ───────────────────────────────────
useEffect(() => {
if (selectedIds.length === 0) {
setEntries([])
return
}
setLoadingEntries(true)
const params = new URLSearchParams({ ids: selectedIds.join(',') })
fetch(`/api/drug-reference/interactions?${params}`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((json: { entries: DrugInteractionEntry[] }) => {
setEntries(json.entries ?? [])
})
.catch(() => {
setEntries([])
})
.finally(() => setLoadingEntries(false))
}, [selectedIds])
// ── Picker search ──────────────────────────────────────────────────────────
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setPickerResults([])
setPickerSearched(false)
return
}
setPickerLoading(true)
setPickerError(null)
try {
const params = new URLSearchParams({ q, limit: '20' })
const resp = await fetch(`/api/drug-reference/search?${params}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const json = (await resp.json()) as { results: DrugSearchResult[] }
setPickerResults(json.results ?? [])
setPickerSearched(true)
} catch (err) {
setPickerError(err instanceof Error ? err.message : 'Search failed')
} finally {
setPickerLoading(false)
}
}, [])
const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => doSearch(val), DEBOUNCE_MS)
}
// ── Selection actions ──────────────────────────────────────────────────────
const addId = (id: number) => {
if (selectedIds.includes(id)) return
if (selectedIds.length >= MAX_COMPARE) return
setSelectedIds((prev) => [...prev, id])
}
const removeId = (id: number) => {
setSelectedIds((prev) => prev.filter((x) => x !== id))
}
const isEmpty = rowCount === 0
const atMax = selectedIds.length >= MAX_COMPARE
return (
<AppLayout>
<Head title="Compare Drug Interactions" />
<div className="p-4 max-w-7xl mx-auto">
{/* Back nav */}
<Link
href="/drug-reference"
className="inline-flex items-center gap-1 text-sm text-desert-green hover:underline mb-4"
>
<IconArrowLeft size={16} />
Drug Reference
</Link>
<div className="mb-5">
<h1 className="text-2xl font-bold mb-1">Compare Drug Interactions</h1>
<p className="text-sm opacity-70">
View each drug's FDA-labeled interaction warnings side by side. Select up to {MAX_COMPARE} drugs.
</p>
</div>
{/* ── Prominent amber disclaimer — always visible ──────────────────── */}
<div className="flex gap-3 items-start bg-amber-50 border border-amber-300 rounded-lg px-4 py-3 mb-6">
<IconAlertTriangle size={20} className="text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-900 leading-relaxed">
<strong>This shows each drug's own FDA-labeled interaction warnings, individually.</strong>{' '}
It is <strong>not</strong> a cross-drug interaction checker and is{' '}
<strong>not</strong> a substitute for professional medical review. Absence of text
here does not mean a drug is safe to combine.
</p>
</div>
{isEmpty ? (
// ── Empty state (no data ingested) ─────────────────────────────────
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<p className="text-lg font-semibold mb-2">No FDA drug data yet</p>
<p className="mb-6 opacity-70">
Download the openFDA drug-label dataset to enable offline search and comparison.
</p>
<Link href="/drug-reference">
<StyledButton variant="primary" onClick={() => {}}>
Go to Drug Reference to download data
</StyledButton>
</Link>
{ingestStatus && (
<div className="mt-6 text-left">
<IngestStatus status={ingestStatus} />
</div>
)}
</div>
) : (
<>
{/* ── Drug picker ───────────────────────────────────────────────── */}
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700">Select drugs to compare</h2>
{atMax && (
<span className="text-xs text-amber-700 bg-amber-100 border border-amber-200 rounded px-2 py-0.5">
Maximum {MAX_COMPARE} drugs reached
</span>
)}
</div>
{/* Selected chips */}
{selectedIds.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{entries.map((entry) => (
<span
key={entry.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-desert-green text-white"
>
{entry.brand_name ?? entry.generic_name ?? `ID ${entry.id}`}
<button
type="button"
onClick={() => removeId(entry.id)}
aria-label={`Remove ${entry.brand_name ?? entry.generic_name}`}
className="hover:opacity-70 transition-opacity leading-none"
>
×
</button>
</span>
))}
{/* Show placeholder chips for ids still loading */}
{selectedIds
.filter((id) => !entries.find((e) => e.id === id))
.map((id) => (
<span
key={id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-gray-300 text-gray-600 animate-pulse"
>
#{id}
<button
type="button"
onClick={() => removeId(id)}
aria-label={`Remove drug ${id}`}
className="hover:opacity-70 transition-opacity leading-none"
>
×
</button>
</span>
))}
</div>
)}
{/* Search input */}
<input
type="text"
value={query}
onChange={handleQueryChange}
placeholder="Search for a drug to add…"
disabled={atMax}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-desert-green disabled:bg-gray-100 disabled:text-gray-400"
/>
{/* Picker results */}
{pickerLoading && (
<div className="mt-2 text-xs text-gray-500">Searching</div>
)}
{pickerError && (
<div className="mt-2 text-xs text-red-600">{pickerError}</div>
)}
{pickerSearched && pickerResults.length === 0 && !pickerLoading && (
<div className="mt-2 text-xs text-gray-500">No results for "{query}"</div>
)}
{pickerResults.length > 0 && !atMax && (
<div className="mt-2 border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-200 max-h-64 overflow-y-auto bg-white">
{pickerResults.map((r) => (
<PickerRow
key={r.id}
result={r}
selected={selectedIds.includes(r.id)}
onAdd={() => addId(r.id)}
/>
))}
</div>
)}
</div>
{/* ── Comparison columns ────────────────────────────────────────── */}
{selectedIds.length === 0 ? (
<div className="text-center py-12 text-gray-400 border-2 border-dashed border-gray-200 rounded-lg">
<p className="text-sm">Select drugs above to compare their labeled interaction warnings.</p>
</div>
) : loadingEntries ? (
<div className="text-center py-12 text-gray-400">
<p className="text-sm">Loading</p>
</div>
) : (
// Stacked single-column on phones; fixed-min-width columns that
// scroll sideways (never crush) from sm: up, even at MAX_COMPARE.
<div className="flex flex-col gap-3 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:pb-2">
{entries.map((entry) => (
<div key={entry.id} className="w-full sm:flex-none sm:w-60">
<InteractionColumn entry={entry} onRemove={removeId} />
</div>
))}
</div>
)}
</>
)}
{/* ── Source citation (CC0, no-endorsement) ───────────────────────── */}
<footer className="mt-8 pt-4 border-t border-gray-200 text-xs text-gray-500">
<strong>Source:</strong> U.S. Food &amp; Drug Administration drug labeling, via{' '}
<strong>openFDA</strong> public domain (CC0 1.0). NOMAD is not affiliated with or
endorsed by the FDA. Label data is provided as-is; do not rely on it for medical
decisions.
</footer>
</div>
</AppLayout>
)
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
// ─── Picker row ───────────────────────────────────────────────────────────────
interface PickerRowProps {
result: DrugSearchResult
selected: boolean
onAdd: () => void
}
function PickerRow({ result, selected, onAdd }: PickerRowProps) {
return (
<div className="flex items-center justify-between px-3 py-2 hover:bg-gray-50">
{/* Reuse the same visual pattern as DrugResultRow but without a Link */}
<div className="min-w-0 flex-1 mr-3">
<span className="text-sm font-medium text-gray-900 block truncate">
{result.brand_name ?? result.generic_name ?? 'Unknown'}
</span>
{result.brand_name && result.generic_name && (
<span className="text-xs text-gray-500 italic">{result.generic_name}</span>
)}
</div>
{selected ? (
<span className="text-xs text-desert-green font-semibold flex-shrink-0">Added</span>
) : (
<button
type="button"
onClick={onAdd}
className="text-xs px-2.5 py-1 rounded border border-desert-green text-desert-green hover:bg-desert-green hover:text-white transition-colors flex-shrink-0"
>
Add
</button>
)}
</div>
)
}

View File

@ -0,0 +1,219 @@
import { Head, Link } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout'
import { IconArrowLeft, IconAlertTriangle, IconFirstAidKit } from '@tabler/icons-react'
import type { DrugLabelDetail } from '../../../types/drug_reference'
import type { ConditionSummary } from '../../../types/conditions'
import { PRODUCT_TYPES } from '../../../types/drug_reference'
import LabelBlocks from '../../components/drug-reference/LabelBlocks'
interface PageProps {
label: DrugLabelDetail
/** Curated situations this label treats (matched from its indications text). */
situations?: ConditionSummary[]
}
/**
* Drug Reference detail page.
*
* Renders sections in fixed clinical order, each shown only if present.
* Boxed Warning appears first as a prominent red callout.
* Drug Interactions carries a note that this is single-drug label text.
*/
export default function DrugReferenceShow({ label, situations = [] }: PageProps) {
const isRx = label.product_type === PRODUCT_TYPES.RX
const isOtc = label.product_type === PRODUCT_TYPES.OTC
return (
<AppLayout>
<Head title={label.brand_name ?? label.generic_name ?? 'Drug Detail'} />
<div className="p-4 max-w-3xl mx-auto">
{/* Back nav + comparison entry */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<Link
href="/drug-reference"
className="inline-flex items-center gap-1 text-sm text-desert-green hover:underline"
>
<IconArrowLeft size={16} />
Drug Reference
</Link>
<Link
href={`/drug-reference/interactions?ids=${label.id}`}
className="text-xs px-2.5 py-1 rounded border border-desert-green text-desert-green hover:bg-desert-green hover:text-white transition-colors"
>
Add to interaction comparison
</Link>
</div>
{/* ── Header ──────────────────────────────────────────────────────── */}
<div className="mb-6">
<div className="flex flex-wrap items-start gap-2 mb-1">
<h1 className="text-2xl font-bold">
{label.brand_name ?? label.generic_name ?? 'Unknown Drug'}
</h1>
{/* OTC / Rx badge */}
{isRx && (
<span className="inline-block mt-1 px-2 py-0.5 rounded text-xs font-semibold bg-desert-orange/10 text-desert-orange-dark border border-desert-orange/30">
Rx
</span>
)}
{isOtc && (
<span className="inline-block mt-1 px-2 py-0.5 rounded text-xs font-semibold bg-desert-olive/10 text-desert-olive-dark border border-desert-olive/30">
OTC
</span>
)}
</div>
{label.brand_name && label.generic_name && (
<p className="text-base text-gray-600 italic">{label.generic_name}</p>
)}
<dl className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 text-sm">
{label.manufacturer && (
<div>
<dt className="inline font-semibold">Manufacturer: </dt>
<dd className="inline">{label.manufacturer}</dd>
</div>
)}
{label.route && (
<div>
<dt className="inline font-semibold">Route: </dt>
<dd className="inline">{label.route}</dd>
</div>
)}
{label.product_ndc && (
<div>
<dt className="inline font-semibold">NDC: </dt>
<dd className="inline font-mono text-xs">{label.product_ndc}</dd>
</div>
)}
{label.source_updated_at && (
<div>
<dt className="inline font-semibold">Label date: </dt>
<dd className="inline">{label.source_updated_at}</dd>
</div>
)}
</dl>
</div>
{/* ── Fixed clinical section order ─────────────────────────────────── */}
{/* 1. Boxed Warning — red callout, most serious */}
{label.boxed_warning && (
<section className="mb-6 border-2 border-red-600 rounded-lg p-4 bg-red-50">
<div className="flex items-center gap-2 mb-2">
<IconAlertTriangle size={20} className="text-red-600 flex-shrink-0" />
<h2 className="text-base font-bold text-red-700 uppercase tracking-wide">
Boxed Warning
</h2>
</div>
<LabelBlocks text={label.boxed_warning} tone="danger" />
</section>
)}
{/* 2. Indications & Usage */}
{label.indications && (
<LabelSection title="Indications & Usage" body={label.indications} />
)}
{/* Reverse link curated situations this label treats. The other half of
the symbiotic surface: each chip jumps back to Drug Reference with the
situation pre-searched. */}
{situations.length > 0 && (
<section className="mb-6 rounded-2xl border border-desert-stone-lighter/60 bg-desert-sand/40 p-4">
<div className="mb-2 flex items-center gap-2">
<IconFirstAidKit size={18} className="flex-shrink-0 text-desert-olive-dark" />
<h2 className="text-sm font-bold uppercase tracking-wide text-desert-green-darker">
Commonly used for
</h2>
</div>
<div className="flex flex-wrap gap-2">
{situations.map((s) => (
<Link
key={s.slug}
href={`/drug-reference?situation=${encodeURIComponent(s.slug)}`}
className="rounded-full border border-desert-olive/40 bg-white px-3 py-1 text-sm text-desert-olive-dark transition-colors hover:border-desert-olive hover:bg-desert-olive hover:text-white"
>
{s.label}
</Link>
))}
</div>
</section>
)}
{/* 3. Dosage & Administration */}
{label.dosage && (
<LabelSection title="Dosage & Administration" body={label.dosage} />
)}
{/* 4. Warnings */}
{label.warnings && (
<LabelSection title="Warnings" body={label.warnings} />
)}
{/* 5. Drug Interactions — single-drug label text, not a pairwise checker */}
{label.drug_interactions && (
<LabelSection
title="Drug Interactions"
body={label.drug_interactions}
footnote="Single-drug label information — not a cross-drug interaction checker"
/>
)}
{/* 6. Contraindications */}
{label.contraindications && (
<LabelSection title="Contraindications" body={label.contraindications} />
)}
{/* 7. When Using (OTC) */}
{label.when_using && (
<LabelSection title="When Using" body={label.when_using} />
)}
{/* 8. Stop Use (OTC) */}
{label.stop_use && (
<LabelSection title="Stop Use" body={label.stop_use} />
)}
{/* ── Footer citation ───────────────────────────────────────────────── */}
<footer className="mt-8 pt-4 border-t border-gray-200 text-xs text-gray-500 space-y-1">
<p>
<strong>Source:</strong> U.S. Food &amp; Drug Administration drug labeling, via{' '}
<strong>openFDA</strong> public domain (CC0 1.0). NOMAD is not affiliated with or
endorsed by the FDA.
</p>
<p>
Do not rely on this data to make decisions regarding medical care. While every effort
is made to ensure accuracy, you should assume all results are unvalidated.
</p>
{label.set_id && (
<p className="font-mono opacity-60">set_id: {label.set_id}</p>
)}
<p className="opacity-60">Last refreshed: {label.ingested_at.slice(0, 10)}</p>
</footer>
</div>
</AppLayout>
)
}
// ─── Section component ────────────────────────────────────────────────────────
function LabelSection({
title,
body,
footnote,
}: {
title: string
body: string
footnote?: string
}) {
return (
<section className="mb-6">
<h2 className="text-base font-bold mb-2 border-b border-gray-200 pb-1">{title}</h2>
<LabelBlocks text={body} />
{footnote && (
<p className="mt-2 text-xs text-gray-500 italic">{footnote}</p>
)}
</section>
)
}

View File

@ -1,8 +1,10 @@
import {
IconBolt,
IconBox,
IconFirstAidKit,
IconHelp,
IconMapRoute,
IconPill,
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
@ -28,6 +30,31 @@ const MAPS_ITEM = {
poweredBy: null,
}
// Drug Reference + "When to use what" — offline medical reference tiles.
// icon and displayOrder here are a reasonable default; both are open for the
// maintainer to re-pick to fit the dashboard's ordering conventions.
const DRUG_REFERENCE_ITEM = {
label: 'Drug Reference',
to: '/drug-reference',
target: '',
description: 'Offline FDA drug labels: search by drug name',
icon: <IconPill size={48} />,
installed: true,
displayOrder: 5,
poweredBy: null,
}
const CONDITIONS_ITEM = {
label: 'When to use what',
to: '/conditions',
target: '',
description: 'Match a situation (burn, fever, diarrhea) to the right OTC drugs',
icon: <IconFirstAidKit size={48} />,
installed: true,
displayOrder: 6,
poweredBy: null,
}
// System items shown after all apps
const SYSTEM_ITEMS = [
{
@ -88,6 +115,10 @@ export default function Home(props: {
system: {
services: ServiceSlim[]
}
// Server-computed: true when the offline FDA drug dataset is installed or
// installing (curated Medicine tier). Gates the two medical-reference tiles
// below so they only appear once the data exists.
drugReferenceInstalled: boolean
}) {
const items: DashboardItem[] = []
const updateInfo = useUpdateAvailable();
@ -128,6 +159,14 @@ export default function Home(props: {
// Add Maps as a Core Capability
items.push(MAPS_ITEM)
// Add the offline medical-reference tiles only once the FDA drug dataset is
// installed (or installing) via the curated Medicine tier. Both tiles read the
// same drug_labels table, so they gate together off one server-computed flag.
if (props.drugReferenceInstalled) {
items.push(DRUG_REFERENCE_ITEM)
items.push(CONDITIONS_ITEM)
}
// Add system items
items.push(...SYSTEM_ITEMS)

View File

@ -70,12 +70,14 @@
"remark-gfm": "4.0.1",
"sharp": "0.34.5",
"stopword": "3.1.5",
"stream-json": "1.9.1",
"systeminformation": "5.31.6",
"tailwindcss": "4.2.2",
"tar": "7.5.11",
"tesseract.js": "7.0.0",
"url-join": "5.0.0",
"yaml": "2.8.3"
"yaml": "2.8.3",
"yauzl": "3.2.0"
},
"devDependencies": {
"@adonisjs/assembler": "7.8.2",
@ -94,6 +96,8 @@
"@types/react": "19.2.10",
"@types/react-dom": "19.2.3",
"@types/stopword": "2.0.3",
"@types/stream-json": "1.7.7",
"@types/yauzl": "2.10.3",
"eslint": "9.39.2",
"hot-hook": "0.4.0",
"prettier": "3.8.1",
@ -5752,6 +5756,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/stream-chain": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/stream-chain/-/stream-chain-2.1.0.tgz",
"integrity": "sha512-guDyAl6s/CAzXUOWpGK2bHvdiopLIwpGu8v10+lb9hnQOyo4oj/ZUQFOvqFjKGsE3wJP1fpIesCcMvbXuWsqOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/stream-json": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/@types/stream-json/-/stream-json-1.7.7.tgz",
"integrity": "sha512-hHG7cLQ09H/m9i0jzL6UJAeLLxIWej90ECn0svO4T8J0nGcl89xZDQ2ujT4WKlvg0GWkcxJbjIDzW/v7BYUM6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/stream-chain": "*"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@ -5788,6 +5813,16 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
@ -6900,6 +6935,15 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
@ -13739,6 +13783,12 @@
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/pg-connection-string": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
@ -15737,6 +15787,21 @@
"integrity": "sha512-OgLYGVFCNa430WOrj9tYZhQge5yg6vd6JsKredveAqEhdLVQkfrpnQIGjx0L9lLqzL4Kq4J8yNTcfQR/MpBwhg==",
"license": "MIT"
},
"node_modules/stream-chain": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
"integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
"license": "BSD-3-Clause"
},
"node_modules/stream-json": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
"integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
"license": "BSD-3-Clause",
"dependencies": {
"stream-chain": "^2.2.5"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -17344,6 +17409,19 @@
"node": ">=8"
}
},
"node_modules/yauzl": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz",
"integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"pend": "~1.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@ -54,6 +54,8 @@
"@types/react": "19.2.10",
"@types/react-dom": "19.2.3",
"@types/stopword": "2.0.3",
"@types/stream-json": "1.7.7",
"@types/yauzl": "2.10.3",
"eslint": "9.39.2",
"hot-hook": "0.4.0",
"prettier": "3.8.1",
@ -123,12 +125,14 @@
"remark-gfm": "4.0.1",
"sharp": "0.34.5",
"stopword": "3.1.5",
"stream-json": "1.9.1",
"systeminformation": "5.31.6",
"tailwindcss": "4.2.2",
"tar": "7.5.11",
"tesseract.js": "7.0.0",
"url-join": "5.0.0",
"yaml": "2.8.3"
"yaml": "2.8.3",
"yauzl": "3.2.0"
},
"hotHook": {
"boundaries": [

View File

@ -8,7 +8,9 @@
*/
import BenchmarkController from '#controllers/benchmark_controller'
import ChatsController from '#controllers/chats_controller'
import ConditionsController from '#controllers/conditions_controller'
import DocsController from '#controllers/docs_controller'
import DrugReferenceController from '#controllers/drug_reference_controller'
import DownloadsController from '#controllers/downloads_controller'
import EasySetupController from '#controllers/easy_setup_controller'
import HomeController from '#controllers/home_controller'
@ -241,3 +243,39 @@ router
router.post('/settings', [BenchmarkController, 'updateSettings'])
})
.prefix('/api/benchmark')
// Drug Reference v1 — offline FDA drug-label search.
// Page GETs ungated (read-only views). The /api/drug-reference group mirrors
// the /api/maps posture — no localNetworkOnly gate because the only disk write
// is the background ingest job (server-side, triggered but not executed inline).
// /drug-reference/interactions must precede /drug-reference/:id so the literal
// path wins over the param route.
router.get('/drug-reference', [DrugReferenceController, 'index'])
router.get('/drug-reference/interactions', [DrugReferenceController, 'interactions'])
router.get('/drug-reference/:id', [DrugReferenceController, 'show'])
router
.group(() => {
router.get('/search', [DrugReferenceController, 'search'])
router.get('/status', [DrugReferenceController, 'status'])
router.get('/interactions', [DrugReferenceController, 'interactionsApi'])
router.post('/download', [DrugReferenceController, 'download'])
router.post('/ingest', [DrugReferenceController, 'ingest'])
router.post('/reset-ingest', [DrugReferenceController, 'resetIngest'])
router.post('/uninstall', [DrugReferenceController, 'uninstall'])
router.get('/ingest-log', [DrugReferenceController, 'ingestLog'])
})
.prefix('/api/drug-reference')
// "When to use what" — condition-first medical reference (Phase 1).
// Browse a curated grid of first-aid situations (or free-text search a
// situation) and see the matching OTC drugs, each linking to its Drug Reference
// detail. Read-only page GETs, ungated like the /drug-reference page GETs; the
// /api/conditions group only reads drug_labels (no disk write), so it mirrors
// the ungated /api/drug-reference posture.
router.get('/conditions', [ConditionsController, 'index'])
router.get('/conditions/:slug', [ConditionsController, 'show'])
router
.group(() => {
router.get('/drugs', [ConditionsController, 'drugsApi'])
})
.prefix('/api/conditions')

View File

@ -0,0 +1,294 @@
/**
* Standalone gate test for the condition-spine pure helpers.
*
* Japa cannot boot locally without MySQL/Redis, so this file exercises the pure
* helpers (parseConditionsFile, parseConditionEntry, buildIndicationQuery,
* orderOtcFirst, findConditionBySlug, toConditionSummary) directly under
* `node --experimental-strip-types`. Run:
* node --experimental-strip-types tests/standalone/conditions.standalone.ts
*
* Also smoke-checks the shipped curated spine (admin/app/data/conditions.ts)
* parses cleanly and stays a bounded curated list of ~3050 conditions.
*/
import assert from 'node:assert/strict'
import {
parseConditionsFile,
parseConditionEntry,
buildIndicationQuery,
orderOtcFirst,
findConditionBySlug,
toConditionSummary,
situationsForIndications,
OTC_PRODUCT_TYPE,
RX_PRODUCT_TYPE,
} from '../../util/conditions.ts'
import { CONDITIONS_FILE } from '../../app/data/conditions.ts'
import { PRODUCT_TYPES } from '../../types/drug_reference.ts'
import type { DrugSearchResult } from '../../types/drug_reference.ts'
let passed = 0
function check(name: string, fn: () => void) {
fn()
passed++
console.log(` ok - ${name}`)
}
function makeDrug(id: number, product_type: string | null): DrugSearchResult {
return {
id,
brand_name: `Brand ${id}`,
generic_name: `generic ${id}`,
manufacturer: null,
route: null,
product_type,
labelCount: 1,
}
}
// ── parseConditionEntry ───────────────────────────────────────────────────────
check('parseConditionEntry accepts a well-formed entry', () => {
const entry = parseConditionEntry({
slug: 'burns',
label: 'Burns',
category: 'Skin & wounds',
searchTerms: ['burns', 'sunburn'],
})
assert.ok(entry)
assert.equal(entry!.slug, 'burns')
assert.deepEqual(entry!.searchTerms, ['burns', 'sunburn'])
})
check('parseConditionEntry de-dupes searchTerms case-insensitively', () => {
const entry = parseConditionEntry({
slug: 's',
label: 'L',
category: 'C',
searchTerms: ['Burn', 'burn', ' burn ', 'scald'],
})
assert.ok(entry)
assert.deepEqual(entry!.searchTerms, ['Burn', 'scald'])
})
check('parseConditionEntry rejects missing slug/label/category', () => {
assert.equal(parseConditionEntry({ label: 'L', category: 'C', searchTerms: ['x'] }), null)
assert.equal(parseConditionEntry({ slug: 's', category: 'C', searchTerms: ['x'] }), null)
assert.equal(parseConditionEntry({ slug: 's', label: 'L', searchTerms: ['x'] }), null)
})
check('parseConditionEntry rejects an entry with no usable searchTerms', () => {
assert.equal(parseConditionEntry({ slug: 's', label: 'L', category: 'C', searchTerms: [] }), null)
assert.equal(
parseConditionEntry({ slug: 's', label: 'L', category: 'C', searchTerms: ['', ' '] }),
null
)
})
check('parseConditionEntry rejects non-objects', () => {
assert.equal(parseConditionEntry(null), null)
assert.equal(parseConditionEntry('burns'), null)
assert.equal(parseConditionEntry(42), null)
})
// ── parseConditionsFile ───────────────────────────────────────────────────────
check('parseConditionsFile parses a valid file', () => {
const file = parseConditionsFile({
version: '2026-06-07',
conditions: [
{ slug: 'a', label: 'A', category: 'C', searchTerms: ['a'] },
{ slug: 'b', label: 'B', category: 'C', searchTerms: ['b'] },
],
})
assert.equal(file.version, '2026-06-07')
assert.equal(file.conditions.length, 2)
})
check('parseConditionsFile drops malformed entries but keeps good ones', () => {
const file = parseConditionsFile({
version: 'v',
conditions: [
{ slug: 'good', label: 'G', category: 'C', searchTerms: ['g'] },
{ slug: 'bad-no-terms', label: 'B', category: 'C', searchTerms: [] },
'not-an-object',
],
})
assert.equal(file.conditions.length, 1)
assert.equal(file.conditions[0].slug, 'good')
})
check('parseConditionsFile de-dupes by slug, keeping the first', () => {
const file = parseConditionsFile({
version: 'v',
conditions: [
{ slug: 'dup', label: 'First', category: 'C', searchTerms: ['x'] },
{ slug: 'dup', label: 'Second', category: 'C', searchTerms: ['y'] },
],
})
assert.equal(file.conditions.length, 1)
assert.equal(file.conditions[0].label, 'First')
})
check('parseConditionsFile fail-soft on garbage input', () => {
assert.deepEqual(parseConditionsFile(null), { version: 'unknown', conditions: [] })
assert.deepEqual(parseConditionsFile('nope'), { version: 'unknown', conditions: [] })
assert.deepEqual(parseConditionsFile({ version: 'v' }), { version: 'v', conditions: [] })
})
// ── buildIndicationQuery ──────────────────────────────────────────────────────
check('buildIndicationQuery quotes multi-word phrases, passes single words bare', () => {
assert.equal(buildIndicationQuery(['burn', 'sore throat']), 'burn "sore throat"')
})
check('buildIndicationQuery de-dupes and drops empties', () => {
assert.equal(buildIndicationQuery(['burn', 'BURN', '', ' ', 'scald']), 'burn scald')
})
check('buildIndicationQuery strips embedded quotes', () => {
assert.equal(buildIndicationQuery(['sore "throat"']), '"sore throat"')
})
check('buildIndicationQuery returns empty string for all-empty input', () => {
assert.equal(buildIndicationQuery([]), '')
assert.equal(buildIndicationQuery(['', ' ']), '')
})
// ── product-type constants drift-guard ───────────────────────────────────────
check('helper OTC/RX constants match canonical PRODUCT_TYPES', () => {
assert.equal(OTC_PRODUCT_TYPE, PRODUCT_TYPES.OTC)
assert.equal(RX_PRODUCT_TYPE, PRODUCT_TYPES.RX)
})
// ── orderOtcFirst ─────────────────────────────────────────────────────────────
check('orderOtcFirst buckets OTC → other → Rx, preserving relevance order', () => {
const input = [
makeDrug(1, PRODUCT_TYPES.RX),
makeDrug(2, PRODUCT_TYPES.OTC),
makeDrug(3, null),
makeDrug(4, PRODUCT_TYPES.OTC),
makeDrug(5, PRODUCT_TYPES.RX),
]
const out = orderOtcFirst(input)
assert.deepEqual(
out.map((d) => d.id),
[2, 4, 3, 1, 5]
)
})
check('orderOtcFirst does not mutate the input array', () => {
const input = [makeDrug(1, PRODUCT_TYPES.RX), makeDrug(2, PRODUCT_TYPES.OTC)]
const snapshot = input.map((d) => d.id)
orderOtcFirst(input)
assert.deepEqual(
input.map((d) => d.id),
snapshot
)
})
// ── findConditionBySlug / toConditionSummary ─────────────────────────────────
check('findConditionBySlug returns the matching condition or null', () => {
const conditions = parseConditionsFile({
version: 'v',
conditions: [{ slug: 'fever', label: 'Fever', category: 'C', searchTerms: ['fever'] }],
}).conditions
assert.equal(findConditionBySlug(conditions, 'fever')!.label, 'Fever')
assert.equal(findConditionBySlug(conditions, 'nope'), null)
assert.equal(findConditionBySlug(conditions, ''), null)
})
check('toConditionSummary strips searchTerms', () => {
const summary = toConditionSummary({
slug: 'fever',
label: 'Fever',
category: 'Pain',
searchTerms: ['fever', 'reduces fever'],
})
assert.deepEqual(summary, { slug: 'fever', label: 'Fever', category: 'Pain' })
assert.equal('searchTerms' in summary, false)
})
// ── situationsForIndications (reverse link) ───────────────────────────────────
const sampleConditions = parseConditionsFile({
version: 'v',
conditions: [
{ slug: 'fever', label: 'Fever', category: 'Pain', searchTerms: ['fever', 'reduces fever'] },
{ slug: 'pain', label: 'Pain', category: 'Pain', searchTerms: ['pain', 'minor aches'] },
{ slug: 'cough', label: 'Cough', category: 'Cold', searchTerms: ['cough', 'cough suppressant'] },
{
slug: 'sore-throat',
label: 'Sore throat',
category: 'Cold',
searchTerms: ['sore throat', 'throat pain'],
},
],
}).conditions
check('situationsForIndications matches a single-word term, case-insensitively', () => {
const out = situationsForIndications('Temporarily reduces FEVER and relieves minor aches', sampleConditions)
assert.deepEqual(
out.map((c) => c.slug),
['fever', 'pain']
)
})
check('situationsForIndications matches a multi-word phrase term', () => {
const out = situationsForIndications('For the temporary relief of sore throat', sampleConditions)
assert.deepEqual(
out.map((c) => c.slug),
['sore-throat']
)
})
check('situationsForIndications preserves curated order, not text order', () => {
// text mentions cough first, then fever, but curated order is fever before cough
const out = situationsForIndications('Controls cough; also reduces fever', sampleConditions)
assert.deepEqual(
out.map((c) => c.slug),
['fever', 'cough']
)
})
check('situationsForIndications returns summaries (no searchTerms leak)', () => {
const out = situationsForIndications('pain', sampleConditions)
assert.equal(out.length, 1)
assert.equal('searchTerms' in out[0], false)
assert.deepEqual(out[0], { slug: 'pain', label: 'Pain', category: 'Pain' })
})
check('situationsForIndications returns [] for blank/empty/null input', () => {
assert.deepEqual(situationsForIndications('', sampleConditions), [])
assert.deepEqual(situationsForIndications(' ', sampleConditions), [])
assert.deepEqual(situationsForIndications(null, sampleConditions), [])
assert.deepEqual(situationsForIndications(undefined, sampleConditions), [])
})
check('situationsForIndications returns [] when nothing matches', () => {
assert.deepEqual(situationsForIndications('antiseptic for minor cuts', sampleConditions), [])
})
check('situationsForIndications dedupes by slug (one hit per condition)', () => {
// both "pain" and "minor aches" appear; pain must surface exactly once
const out = situationsForIndications('relieves minor aches and pain', sampleConditions)
assert.equal(out.filter((c) => c.slug === 'pain').length, 1)
})
// ── shipped spine smoke check ─────────────────────────────────────────────────
check('shipped CONDITIONS_FILE parses cleanly with no dropped entries', () => {
const parsed = parseConditionsFile(CONDITIONS_FILE)
assert.equal(parsed.conditions.length, CONDITIONS_FILE.conditions.length)
})
check('shipped spine holds a bounded curated list of ~3050 conditions', () => {
const n = CONDITIONS_FILE.conditions.length
assert.ok(n >= 30 && n <= 50, `expected 3050 conditions, got ${n}`)
})
check('shipped spine has unique slugs and non-empty searchTerms', () => {
const slugs = new Set<string>()
for (const c of CONDITIONS_FILE.conditions) {
assert.ok(!slugs.has(c.slug), `duplicate slug: ${c.slug}`)
slugs.add(c.slug)
assert.ok(c.searchTerms.length > 0, `empty searchTerms: ${c.slug}`)
assert.ok(buildIndicationQuery(c.searchTerms).length > 0, `empty query: ${c.slug}`)
}
})
console.log(`\n${passed} checks passed`)

View File

@ -0,0 +1,183 @@
/**
* Standalone gate test for the two-step ingest pure helpers.
*
* Japa cannot boot locally without MySQL/Redis, so this file exercises the pure
* helpers (partZipPath, parseDownloadState, deriveIngestPhase) directly under
* `node --experimental-strip-types`. It mirrors the Japa spec at
* tests/unit/drug_ingest_status.spec.ts. Run:
* node --experimental-strip-types tests/standalone/drug_ingest_status.standalone.ts
*/
import assert from 'node:assert/strict'
import {
partZipPath,
parseDownloadState,
deriveIngestPhase,
resolveExpectedTotal,
resolveIngestRecordsShown,
} from '../../util/drug_labels.ts'
import type {
DrugLabelPartition,
DrugDownloadStatus,
DrugIngestPhaseStatus,
} from '../../types/drug_reference.ts'
let passed = 0
function check(name: string, fn: () => void) {
fn()
passed++
console.log(` ok - ${name}`)
}
// ── partZipPath ──────────────────────────────────────────────────────────────
const p1: DrugLabelPartition = {
display_name: 'part 1',
file: 'https://download.open.fda.gov/drug/label/drug-label-0001-of-0013.json.zip',
size_mb: '128',
records: 20000,
}
check('partZipPath joins base + basename', () => {
assert.equal(
partZipPath('/app/storage/drug-data', p1),
'/app/storage/drug-data/drug-label-0001-of-0013.json.zip'
)
})
check('partZipPath round-trips a recorded path', () => {
const recorded = '/app/storage/drug-data/drug-label-0007-of-0013.json.zip'
assert.equal(
partZipPath('/app/storage/drug-data', {
display_name: 'p7',
file: recorded,
size_mb: '0',
records: 0,
}),
recorded
)
})
// ── parseDownloadState ───────────────────────────────────────────────────────
check('parseDownloadState parses a valid marker', () => {
const raw = JSON.stringify({
export_date: '2026-06-06',
totalParts: 2,
totalRecords: 258914,
parts: [
{ index: 0, name: 'part 1', path: '/app/storage/drug-data/p1.zip', bytes: 100 },
{ index: 1, name: 'part 2', path: '/app/storage/drug-data/p2.zip', bytes: 200 },
],
completedAtMs: 1717718400000,
})
const m = parseDownloadState(raw)
assert.ok(m)
assert.equal(m!.totalParts, 2)
assert.equal(m!.totalRecords, 258914)
assert.equal(m!.parts[1].path, '/app/storage/drug-data/p2.zip')
})
check('parseDownloadState defaults totalRecords to 0 on older markers', () => {
const m = parseDownloadState(
JSON.stringify({
export_date: 'x',
totalParts: 1,
parts: [{ index: 0, name: 'p', path: '/p.zip', bytes: 1 }],
})
)
assert.ok(m)
assert.equal(m!.totalRecords, 0)
})
check('parseDownloadState null for null input', () => assert.equal(parseDownloadState(null), null))
check('parseDownloadState null for malformed JSON', () =>
assert.equal(parseDownloadState('{not json'), null))
check('parseDownloadState null for empty parts', () =>
assert.equal(
parseDownloadState(JSON.stringify({ export_date: 'x', totalParts: 0, parts: [] })),
null
))
check('parseDownloadState null when a part lacks path', () =>
assert.equal(
parseDownloadState(
JSON.stringify({ export_date: 'x', totalParts: 1, parts: [{ index: 0, name: 'p', bytes: 1 }] })
),
null
))
check('parseDownloadState defaults index/name/bytes', () => {
const m = parseDownloadState(
JSON.stringify({ export_date: 'x', totalParts: 1, parts: [{ path: '/p.zip' }] })
)
assert.ok(m)
assert.equal(m!.parts[0].index, 0)
assert.equal(m!.parts[0].name, '')
assert.equal(m!.parts[0].bytes, 0)
})
// ── deriveIngestPhase ────────────────────────────────────────────────────────
const dl = (state: DrugDownloadStatus['state']): DrugDownloadStatus => ({
state,
partsDone: 0,
totalParts: 13,
currentPartName: null,
})
const ing = (state: DrugIngestPhaseStatus['state']): DrugIngestPhaseStatus => ({
state,
records: 0,
expectedTotal: 0,
partsDone: 0,
totalParts: 13,
currentPartName: null,
})
check('deriveIngestPhase idle', () => assert.equal(deriveIngestPhase(dl('idle'), ing('idle'), 0), 'idle'))
check('deriveIngestPhase downloading', () =>
assert.equal(deriveIngestPhase(dl('running'), ing('idle'), 0), 'downloading'))
check('deriveIngestPhase downloaded', () =>
assert.equal(deriveIngestPhase(dl('completed'), ing('idle'), 0), 'downloaded'))
check('deriveIngestPhase ingesting', () =>
assert.equal(deriveIngestPhase(dl('completed'), ing('running'), 0), 'ingesting'))
check('deriveIngestPhase ready (ingest completed)', () =>
assert.equal(deriveIngestPhase(dl('completed'), ing('completed'), 250000), 'ready'))
check('deriveIngestPhase ready (idle with rows)', () =>
assert.equal(deriveIngestPhase(dl('idle'), ing('idle'), 250000), 'ready'))
check('deriveIngestPhase failed (ingest)', () =>
assert.equal(deriveIngestPhase(dl('completed'), ing('failed'), 0), 'failed'))
check('deriveIngestPhase failed (download)', () =>
assert.equal(deriveIngestPhase(dl('failed'), ing('idle'), 0), 'failed'))
check('deriveIngestPhase running ingest beats download failure', () =>
assert.equal(deriveIngestPhase(dl('failed'), ing('running'), 0), 'ingesting'))
check('deriveIngestPhase running download beats ingest failure', () =>
assert.equal(deriveIngestPhase(dl('running'), ing('failed'), 0), 'downloading'))
check('deriveIngestPhase completed download + failed ingest -> failed', () =>
assert.equal(deriveIngestPhase(dl('completed'), ing('failed'), 0), 'failed'))
// ── resolveExpectedTotal (the 0 ?? fallback bug) ─────────────────────────────
check('resolveExpectedTotal prefers a real manifest total', () =>
assert.equal(resolveExpectedTotal(258914, 13, 5000), 258914))
check('resolveExpectedTotal treats manifest 0 as unknown → parts estimate', () =>
assert.equal(resolveExpectedTotal(0, 13, 5000), 13 * 20000))
check('resolveExpectedTotal treats manifest undefined as unknown → parts estimate', () =>
assert.equal(resolveExpectedTotal(undefined, 13, 5000), 13 * 20000))
check('resolveExpectedTotal falls back to row count when parts also 0', () =>
assert.equal(resolveExpectedTotal(0, 0, 5000), 5000))
check('resolveExpectedTotal falls back to row count when parts undefined', () =>
assert.equal(resolveExpectedTotal(undefined, undefined, 5000), 5000))
check('resolveExpectedTotal honors a custom per-part estimate', () =>
assert.equal(resolveExpectedTotal(0, 4, 0, 25000), 4 * 25000))
// ── resolveIngestRecordsShown (continuation-handoff progress lag) ────────────
check('resolveIngestRecordsShown: first ingest tracks live row count climbing', () =>
// Empty table fills: jobRecords lags at 12000, live rowCount has reached 45000.
assert.equal(resolveIngestRecordsShown(12000, 45000, 259000), 45000))
check('resolveIngestRecordsShown: re-ingest rides the per-job counter', () =>
// Populated table (rowCount static ~259000), re-ingest jobRecords climbing 80000.
// max would pick rowCount, but it is clamped to expectedTotal so it never over-reads;
// here rowCount === expectedTotal so the clamp holds it at the denominator.
assert.equal(resolveIngestRecordsShown(80000, 259000, 259000), 259000))
check('resolveIngestRecordsShown: re-ingest where rowCount sits below total', () =>
// rowCount 200000 static, jobRecords overtakes at 210000 → per-job count wins.
assert.equal(resolveIngestRecordsShown(210000, 200000, 259000), 210000))
check('resolveIngestRecordsShown: clamps live count to expectedTotal', () =>
// Live count momentarily reads past the denominator → clamp to expectedTotal.
assert.equal(resolveIngestRecordsShown(0, 260000, 259000), 259000))
check('resolveIngestRecordsShown: unknown total (0) returns the raw max', () =>
assert.equal(resolveIngestRecordsShown(5000, 9000, 0), 9000))
check('resolveIngestRecordsShown: both zero at start', () =>
assert.equal(resolveIngestRecordsShown(0, 0, 259000), 0))
console.log(`\nAll ${passed} standalone assertions passed.`)

View File

@ -0,0 +1,153 @@
/**
* Standalone gate test for the drug-interaction parser.
*
* Japa cannot boot locally without MySQL/Redis, so this file exercises the pure
* parser directly under `node --experimental-strip-types`. It mirrors the Japa
* spec at tests/unit/drug_interactions.spec.ts. Run:
* node --experimental-strip-types tests/standalone/drug_interactions.standalone.ts
*/
import assert from 'node:assert/strict'
import {
parseInteractions,
parseLabelSection,
isSectionHeader,
isLabelSectionHeader,
type InteractionBlock,
} from '../../util/drug_interactions.ts'
const MAXALT =
'7 DRUG INTERACTIONS 7.1 Propranolol The dose of MAXALT should be adjusted in ' +
'propranolol-treated patients, as propranolol has been shown to increase the plasma ' +
'AUC of rizatriptan by 70% [see Dosage and Administration (2.4) and Clinical Pharmacology (12.3)] . ' +
'7.2 Ergot-Containing Drugs Ergot-containing drugs have been reported to cause prolonged ' +
'vasospastic reactions. 7.3 Other 5-HT 1 Agonists Because their vasospastic effects may be ' +
'additive, co-administration of MAXALT and other 5-HT 1 agonists within 24 hours of each other ' +
'is contraindicated [see Contraindications (4)] . 7.5 Monoamine Oxidase Inhibitors MAXALT is ' +
'contraindicated in patients taking MAO-A inhibitors and non-selective MAO inhibitors.'
const ADVAIR =
'7 DRUG INTERACTIONS ADVAIR DISKUS has been used concomitantly with other drugs without ' +
'adverse drug reactions [see Clinical Pharmacology ( 12.2 )] . No formal drug interaction trials ' +
'have been performed with ADVAIR DISKUS . • Strong cytochrome P450 3A4 inhibitors (e.g., ritonavir, ' +
'ketoconazole): Use not recommended. ( 7.1 ) • Monoamine oxidase inhibitors and tricyclic ' +
'antidepressants: Use with extreme caution. ( 7.2 ) • Beta-blockers: Use with caution. ( 7.3 ) ' +
'7.1 Inhibitors of Cytochrome P450 3A4 Fluticasone propionate and salmeterol are substrates of CYP3A4.'
// Other FDA label sections — the generalized parser must read each section's own
// number (2 for dosage, 5 for warnings) and never split on cross-refs to others.
const DOSAGE =
'2 DOSAGE AND ADMINISTRATION 2.1 Recommended Dosage The recommended dosage is 10 mg ' +
'orally once daily [see Clinical Pharmacology (12.3)] . 2.2 Dose Adjustment in Renal ' +
'Impairment Reduce the dose to 5 mg in patients with severe renal impairment. 2.3 ' +
'Administration Swallow tablets whole; do not crush.'
const WARNINGS =
'5 WARNINGS AND PRECAUTIONS 5.1 Hypersensitivity Reactions Anaphylaxis has been reported. ' +
'Discontinue if a reaction occurs. 5.2 Hepatotoxicity Monitor liver enzymes. 5.3 Embryo-Fetal ' +
'Toxicity Can cause fetal harm [see Use in Specific Populations (8.1)] .'
// Subsections present but no leading "N SECTION NAME" header line.
const NO_HEADER = '3.1 Tablets 10 mg, white, round. 3.2 Oral Solution 5 mg/5 mL, clear.'
const PLAIN = 'There are no known contraindications for this product.'
function words(s: string): string[] {
return s.replace(/•/g, ' ').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
}
function reconstruct(blocks: InteractionBlock[]): string {
return blocks
.map((b) => {
const parts: string[] = []
if (b.label) parts.push(b.label)
if (b.text) parts.push(b.text)
if (b.bullets) parts.push(b.bullets.join(' '))
return parts.join(' ')
})
.join(' ')
}
let passed = 0
function check(name: string, fn: () => void) {
fn()
passed++
console.log(` ok - ${name}`)
}
// ── content fidelity (safety) ─────────────────────────────────────────────────
check('preserves every word of numbered (MAXALT) text', () => {
assert.deepEqual(words(reconstruct(parseInteractions(MAXALT))), words(MAXALT))
})
check('preserves every word of bulleted (ADVAIR) text', () => {
assert.deepEqual(words(reconstruct(parseInteractions(ADVAIR))), words(ADVAIR))
})
// ── structure ─────────────────────────────────────────────────────────────────
check('splits numbered text into labeled subsections', () => {
const labels = parseInteractions(MAXALT)
.map((b) => b.label)
.filter((l): l is string => l !== null)
assert.deepEqual(labels, ['7.1', '7.2', '7.3', '7.5'])
})
check('does not split on parenthetical cross-references', () => {
const labels = parseInteractions(MAXALT).map((b) => b.label)
assert.ok(!labels.includes('2.4'))
assert.ok(!labels.includes('12.3'))
})
check('renders bulleted text as a bullet block', () => {
const blocks = parseInteractions(ADVAIR)
const bulletBlock = blocks.find((b) => b.bullets !== null)
assert.ok(bulletBlock, 'expected a bullet block')
assert.ok(bulletBlock!.bullets!.length > 2)
assert.ok(blocks.map((b) => b.label).includes('7.1'))
})
check('separates the section header from the body', () => {
assert.ok(isSectionHeader(parseInteractions(MAXALT)[0].text))
})
check('empty or null input yields no blocks', () => {
assert.deepEqual(parseInteractions(null), [])
assert.deepEqual(parseInteractions(''), [])
assert.deepEqual(parseInteractions(' '), [])
})
// ── generalized to all FDA label sections ─────────────────────────────────────
check('preserves every word of a dosage section', () => {
assert.deepEqual(words(reconstruct(parseLabelSection(DOSAGE))), words(DOSAGE))
})
check('splits a dosage section on its own (2.x) numbers, not cross-refs', () => {
const labels = parseLabelSection(DOSAGE)
.map((b) => b.label)
.filter((l): l is string => l !== null)
assert.deepEqual(labels, ['2.1', '2.2', '2.3'])
})
check('preserves every word of a warnings section', () => {
assert.deepEqual(words(reconstruct(parseLabelSection(WARNINGS))), words(WARNINGS))
})
check('warnings keeps its (5.x) numbers and ignores the (8.1) cross-ref', () => {
const labels = parseLabelSection(WARNINGS).map((b) => b.label)
assert.deepEqual(
labels.filter((l): l is string => l !== null),
['5.1', '5.2', '5.3']
)
assert.ok(!labels.includes('8.1'))
})
check('falls back to the dominant subsection number with no leading header', () => {
const labels = parseLabelSection(NO_HEADER)
.map((b) => b.label)
.filter((l): l is string => l !== null)
assert.deepEqual(labels, ['3.1', '3.2'])
assert.deepEqual(words(reconstruct(parseLabelSection(NO_HEADER))), words(NO_HEADER))
})
check('plain text with no structure is one preserved block', () => {
const blocks = parseLabelSection(PLAIN)
assert.equal(blocks.length, 1)
assert.equal(blocks[0].text, PLAIN)
})
check('isLabelSectionHeader matches numbered all-caps headers only', () => {
assert.ok(isLabelSectionHeader('2 DOSAGE AND ADMINISTRATION'))
assert.ok(isLabelSectionHeader('5 WARNINGS'))
assert.ok(!isLabelSectionHeader('DO NOT EXCEED 4 DOSES'))
assert.ok(!isLabelSectionHeader('2.1 Recommended Dosage'))
})
console.log(`\n${passed} checks passed`)

View File

@ -0,0 +1,81 @@
/**
* Standalone gate test for the tier-rework pure helpers added in the
* drug-reference opt-in-tier rework (PR #1040 follow-up):
* - manifestBytesTotal (Active Downloads card totalBytes)
* - isExportDateNewer (drug auto-update freshness compare)
*
* Japa can't boot locally without MySQL/Redis, so these exercise the pure
* helpers directly under `node --experimental-strip-types`. Run:
* node --experimental-strip-types tests/standalone/drug_tier_rework.standalone.ts
*/
import assert from 'node:assert/strict'
import { manifestBytesTotal, isExportDateNewer } from '../../util/drug_labels.ts'
import type { DrugLabelManifest } from '../../types/drug_reference.ts'
let passed = 0
function check(name: string, fn: () => void) {
fn()
passed++
console.log(` ok - ${name}`)
}
const MB = 1024 * 1024
function manifest(sizes: string[]): DrugLabelManifest {
return {
export_date: '2025-01-01',
total_records: 1000,
partitions: sizes.map((s, i) => ({
display_name: `part ${i + 1}`,
file: `https://download.open.fda.gov/drug/label/p-${i}.json.zip`,
size_mb: s,
records: 100,
})),
}
}
// ── manifestBytesTotal ────────────────────────────────────────────────────────
check('manifestBytesTotal sums partition MB into bytes', () =>
assert.equal(manifestBytesTotal(manifest(['100', '200', '50'])), 350 * MB))
check('manifestBytesTotal treats a non-numeric size as 0 (no NaN)', () =>
assert.equal(manifestBytesTotal(manifest(['100', 'oops', '50'])), 150 * MB))
check('manifestBytesTotal treats a negative/zero size as 0', () =>
assert.equal(manifestBytesTotal(manifest(['100', '0', '-5'])), 100 * MB))
check('manifestBytesTotal of an empty partition list is 0', () =>
assert.equal(manifestBytesTotal(manifest([])), 0))
// ── isExportDateNewer ─────────────────────────────────────────────────────────
// No baseline → any valid candidate is "newer" (first install with no marker).
check('isExportDateNewer: empty baseline is always newer', () =>
assert.equal(isExportDateNewer('2025-06-01', null), true))
check('isExportDateNewer: empty-string baseline is always newer', () =>
assert.equal(isExportDateNewer('2025-06-01', ''), true))
// Empty candidate → nothing to update to.
check('isExportDateNewer: empty candidate is never newer', () =>
assert.equal(isExportDateNewer('', '2025-06-01'), false))
// Identical → already current.
check('isExportDateNewer: identical dates are not newer', () =>
assert.equal(isExportDateNewer('2025-06-01', '2025-06-01'), false))
// ISO YYYY-MM-DD — both Date.parse paths and lexicographic agree.
check('isExportDateNewer: ISO later date is newer', () =>
assert.equal(isExportDateNewer('2025-06-02', '2025-06-01'), true))
check('isExportDateNewer: ISO earlier date is not newer', () =>
assert.equal(isExportDateNewer('2025-05-31', '2025-06-01'), false))
// Cross-month / cross-year ISO ordering.
check('isExportDateNewer: ISO across year boundary', () =>
assert.equal(isExportDateNewer('2026-01-01', '2025-12-31'), true))
// US M/D/YYYY format — Date.parse handles this; a naive lexicographic compare
// would get it WRONG ('1/2/2026' < '12/31/2025' lexically). This proves the
// Date.parse-first path is doing the work.
check('isExportDateNewer: US-format newer date (Date.parse path)', () =>
assert.equal(isExportDateNewer('1/2/2026', '12/31/2025'), true))
check('isExportDateNewer: US-format older date (Date.parse path)', () =>
assert.equal(isExportDateNewer('12/30/2025', '12/31/2025'), false))
// Zero-padded compact YYYYMMDD — Date.parse rejects it, lexicographic fallback
// is correct for this zero-padded ordered form.
check('isExportDateNewer: compact YYYYMMDD newer (lexicographic fallback)', () =>
assert.equal(isExportDateNewer('20260102', '20251231'), true))
check('isExportDateNewer: compact YYYYMMDD older (lexicographic fallback)', () =>
assert.equal(isExportDateNewer('20251230', '20251231'), false))
console.log(`\nAll ${passed} standalone assertions passed.`)

View File

@ -0,0 +1,332 @@
/**
* Standalone gate test for the natural-remedies pure helpers (Phase 2).
*
* Japa cannot boot locally without MySQL/Redis, so this file exercises the pure
* helpers (parseNaturalRemediesFile, parseRemedyEntry, remediesForCondition,
* remediesForFreeText) directly under `node --experimental-strip-types`. Run:
* node --experimental-strip-types tests/standalone/natural_remedies.standalone.ts
*
* Also asserts that admin/app/data/natural_remedies.ts stays in sync with
* collections/natural_remedies.json (same version + same remedy count + same slugs)
* and that every remedy condition slug exists in the conditions spine.
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
parseNaturalRemediesFile,
parseRemedyEntry,
remediesForCondition,
remediesForFreeText,
} from '../../util/conditions.ts'
import { NATURAL_REMEDIES_FILE } from '../../app/data/natural_remedies.ts'
import { CONDITIONS_FILE } from '../../app/data/conditions.ts'
import { parseConditionsFile } from '../../util/conditions.ts'
import type { NaturalRemediesFile } from '../../types/conditions.ts'
let passed = 0
function check(name: string, fn: () => void) {
fn()
passed++
console.log(` ok - ${name}`)
}
// ── parseRemedyEntry ──────────────────────────────────────────────────────────
check('parseRemedyEntry accepts a well-formed entry', () => {
const entry = parseRemedyEntry({
slug: 'ginger',
name: 'Ginger',
commonNames: ['Zingiber officinale'],
conditions: ['nausea-vomiting'],
uses: 'Used for nausea.',
evidence: 'Some evidence.',
cautions: 'May interact with blood thinners.',
sourceUrl: 'https://www.nccih.nih.gov/health/ginger',
})
assert.ok(entry)
assert.equal(entry!.slug, 'ginger')
assert.equal(entry!.name, 'Ginger')
assert.deepEqual(entry!.commonNames, ['Zingiber officinale'])
assert.deepEqual(entry!.conditions, ['nausea-vomiting'])
})
check('parseRemedyEntry rejects missing required string fields', () => {
// missing slug
assert.equal(
parseRemedyEntry({
name: 'Ginger',
commonNames: [],
conditions: ['nausea'],
uses: 'u',
evidence: 'e',
cautions: 'c',
sourceUrl: 'https://example.com',
}),
null
)
// missing uses
assert.equal(
parseRemedyEntry({
slug: 'ginger',
name: 'Ginger',
commonNames: [],
conditions: ['nausea'],
evidence: 'e',
cautions: 'c',
sourceUrl: 'https://example.com',
}),
null
)
})
check('parseRemedyEntry rejects an entry with no usable conditions', () => {
assert.equal(
parseRemedyEntry({
slug: 'g',
name: 'G',
commonNames: [],
conditions: [],
uses: 'u',
evidence: 'e',
cautions: 'c',
sourceUrl: 'https://example.com',
}),
null
)
})
check('parseRemedyEntry rejects non-objects', () => {
assert.equal(parseRemedyEntry(null), null)
assert.equal(parseRemedyEntry('ginger'), null)
assert.equal(parseRemedyEntry(42), null)
})
check('parseRemedyEntry keeps non-empty commonNames, drops empties', () => {
const entry = parseRemedyEntry({
slug: 'g',
name: 'G',
commonNames: ['name', '', ' ', 'other'],
conditions: ['c'],
uses: 'u',
evidence: 'e',
cautions: 'c',
sourceUrl: 'https://example.com',
})
assert.ok(entry)
assert.deepEqual(entry!.commonNames, ['name', 'other'])
})
// ── parseNaturalRemediesFile ──────────────────────────────────────────────────
check('parseNaturalRemediesFile parses a valid file', () => {
const file = parseNaturalRemediesFile({
version: '2026-06-09',
source: { name: 'NCCIH', url: 'https://nccih.nih.gov', license: 'Public domain' },
remedies: [
{
slug: 'ginger',
name: 'Ginger',
commonNames: ['Zingiber officinale'],
conditions: ['nausea-vomiting'],
uses: 'Used for nausea.',
evidence: 'Some evidence.',
cautions: 'See a clinician.',
sourceUrl: 'https://www.nccih.nih.gov/health/ginger',
},
],
})
assert.equal(file.version, '2026-06-09')
assert.equal(file.source.name, 'NCCIH')
assert.equal(file.remedies.length, 1)
assert.equal(file.remedies[0].slug, 'ginger')
})
check('parseNaturalRemediesFile drops malformed entries but keeps good ones', () => {
const file = parseNaturalRemediesFile({
version: 'v',
source: { name: 'S', url: 'u', license: 'l' },
remedies: [
{
slug: 'good',
name: 'Good',
commonNames: [],
conditions: ['pain'],
uses: 'u',
evidence: 'e',
cautions: 'c',
sourceUrl: 'https://example.com',
},
{ slug: 'bad-no-conditions', name: 'Bad', commonNames: [], conditions: [], uses: 'u', evidence: 'e', cautions: 'c', sourceUrl: 'https://example.com' },
'not-an-object',
],
})
assert.equal(file.remedies.length, 1)
assert.equal(file.remedies[0].slug, 'good')
})
check('parseNaturalRemediesFile de-dupes by slug, keeping the first', () => {
const file = parseNaturalRemediesFile({
version: 'v',
source: { name: '', url: '', license: '' },
remedies: [
{ slug: 'dup', name: 'First', commonNames: [], conditions: ['pain'], uses: 'u', evidence: 'e', cautions: 'c', sourceUrl: 'https://example.com' },
{ slug: 'dup', name: 'Second', commonNames: [], conditions: ['pain'], uses: 'u2', evidence: 'e2', cautions: 'c2', sourceUrl: 'https://example.com' },
],
})
assert.equal(file.remedies.length, 1)
assert.equal(file.remedies[0].name, 'First')
})
check('parseNaturalRemediesFile fail-soft on garbage input', () => {
const empty = parseNaturalRemediesFile(null)
assert.equal(empty.version, 'unknown')
assert.deepEqual(empty.remedies, [])
const noRemedies = parseNaturalRemediesFile({ version: 'v', source: { name: '', url: '', license: '' } })
assert.deepEqual(noRemedies.remedies, [])
})
// ── remediesForCondition ──────────────────────────────────────────────────────
const sampleFile = parseNaturalRemediesFile({
version: 'v',
source: { name: 'S', url: 'u', license: 'l' },
remedies: [
{ slug: 'ginger', name: 'Ginger', commonNames: [], conditions: ['nausea-vomiting', 'indigestion'], uses: 'u', evidence: 'e', cautions: 'c', sourceUrl: 'https://example.com/ginger' },
{ slug: 'chamomile', name: 'Chamomile', commonNames: [], conditions: ['sleeplessness', 'indigestion'], uses: 'u', evidence: 'e', cautions: 'c', sourceUrl: 'https://example.com/chamomile' },
{ slug: 'aloe-vera', name: 'Aloe Vera', commonNames: [], conditions: ['burns'], uses: 'u', evidence: 'e', cautions: 'c', sourceUrl: 'https://example.com/aloe-vera' },
],
})
check('remediesForCondition returns remedies whose conditions[] includes the slug', () => {
const result = remediesForCondition(sampleFile, 'indigestion')
assert.equal(result.length, 2)
assert.deepEqual(
result.map((r) => r.slug),
['ginger', 'chamomile']
)
})
check('remediesForCondition returns [] when slug matches nothing', () => {
assert.deepEqual(remediesForCondition(sampleFile, 'fever'), [])
})
check('remediesForCondition returns [] for empty slug', () => {
assert.deepEqual(remediesForCondition(sampleFile, ''), [])
assert.deepEqual(remediesForCondition(sampleFile, ' '), [])
})
check('remediesForCondition preserves stable curated order', () => {
const result = remediesForCondition(sampleFile, 'indigestion')
assert.equal(result[0].slug, 'ginger')
assert.equal(result[1].slug, 'chamomile')
})
check('remediesForCondition returns [] for empty remedies file', () => {
const emptyFile = parseNaturalRemediesFile({ version: 'v', source: { name: '', url: '', license: '' }, remedies: [] })
assert.deepEqual(remediesForCondition(emptyFile, 'burns'), [])
})
// ── remediesForFreeText ───────────────────────────────────────────────────────
check('remediesForFreeText matches on name (case-insensitive)', () => {
const result = remediesForFreeText(sampleFile, 'GINGER')
assert.equal(result.length, 1)
assert.equal(result[0].slug, 'ginger')
})
check('remediesForFreeText matches on uses substring', () => {
// All three have uses === 'u', so a substring match on 'u' returns all
const result = remediesForFreeText(sampleFile, 'aloe')
assert.equal(result.length, 1)
assert.equal(result[0].slug, 'aloe-vera')
})
check('remediesForFreeText returns [] for empty query', () => {
assert.deepEqual(remediesForFreeText(sampleFile, ''), [])
assert.deepEqual(remediesForFreeText(sampleFile, ' '), [])
})
check('remediesForFreeText dedupes by slug', () => {
// "indigestion" should not appear twice even if name AND uses both match
const result = remediesForFreeText(sampleFile, 'Ginger')
assert.equal(result.filter((r) => r.slug === 'ginger').length, 1)
})
// ── sync check: NATURAL_REMEDIES_FILE ↔ collections/natural_remedies.json ────
check('NATURAL_REMEDIES_FILE parses cleanly with no dropped entries', () => {
const parsed = parseNaturalRemediesFile(NATURAL_REMEDIES_FILE)
assert.equal(parsed.remedies.length, NATURAL_REMEDIES_FILE.remedies.length)
})
check('NATURAL_REMEDIES_FILE matches collections/natural_remedies.json in version + count + slugs', () => {
// This test file is at admin/tests/standalone/; ../../../ reaches the repo root.
const __dir = dirname(fileURLToPath(import.meta.url))
const jsonPath = resolve(__dir, '../../../collections/natural_remedies.json')
const raw = JSON.parse(readFileSync(jsonPath, 'utf8')) as NaturalRemediesFile
// version
assert.equal(
NATURAL_REMEDIES_FILE.version,
raw.version,
`version mismatch: TS=${NATURAL_REMEDIES_FILE.version} JSON=${raw.version}`
)
// remedy count
assert.equal(
NATURAL_REMEDIES_FILE.remedies.length,
raw.remedies.length,
`remedy count mismatch: TS=${NATURAL_REMEDIES_FILE.remedies.length} JSON=${raw.remedies.length}`
)
// slug set equality
const tsSlugs = new Set(NATURAL_REMEDIES_FILE.remedies.map((r) => r.slug))
const jsonSlugs = new Set(raw.remedies.map((r: { slug: string }) => r.slug))
for (const slug of tsSlugs) {
assert.ok(jsonSlugs.has(slug), `slug in TS but not in JSON: ${slug}`)
}
for (const slug of jsonSlugs) {
assert.ok(tsSlugs.has(slug), `slug in JSON but not in TS: ${slug}`)
}
})
check('every remedy condition slug exists in the conditions spine', () => {
const spine = parseConditionsFile(CONDITIONS_FILE)
const knownSlugs = new Set(spine.conditions.map((c) => c.slug))
const unknown: string[] = []
for (const remedy of NATURAL_REMEDIES_FILE.remedies) {
for (const condSlug of remedy.conditions) {
if (!knownSlugs.has(condSlug)) {
unknown.push(`${remedy.slug} → "${condSlug}"`)
}
}
}
assert.equal(
unknown.length,
0,
`Unknown condition slugs in remedies:\n ${unknown.join('\n ')}`
)
})
check('shipped NATURAL_REMEDIES_FILE has the 18 curated remedies', () => {
const n = NATURAL_REMEDIES_FILE.remedies.length
assert.equal(n, 18, `expected 18 remedies, got ${n}`)
})
check('shipped remedies have unique slugs and non-empty required fields', () => {
const slugs = new Set<string>()
for (const r of NATURAL_REMEDIES_FILE.remedies) {
assert.ok(!slugs.has(r.slug), `duplicate slug: ${r.slug}`)
slugs.add(r.slug)
assert.ok(r.name.trim().length > 0, `empty name: ${r.slug}`)
assert.ok(r.uses.trim().length > 0, `empty uses: ${r.slug}`)
assert.ok(r.cautions.trim().length > 0, `empty cautions: ${r.slug}`)
assert.ok(r.sourceUrl.startsWith('https://'), `bad sourceUrl: ${r.slug}`)
assert.ok(r.conditions.length > 0, `no conditions: ${r.slug}`)
}
})
console.log(`\n${passed} checks passed`)

View File

@ -0,0 +1,204 @@
import { test } from '@japa/runner'
import {
partZipPath,
parseDownloadState,
deriveIngestPhase,
resolveExpectedTotal,
} from '../../util/drug_labels.js'
import type {
DrugLabelPartition,
DrugDownloadStatus,
DrugIngestPhaseStatus,
} from '../../types/drug_reference.js'
// ─── partZipPath ──────────────────────────────────────────────────────────────
test.group('partZipPath', () => {
test('joins the storage base with the basename of the file URL', ({ assert }) => {
const partition: DrugLabelPartition = {
display_name: 'part 1',
file: 'https://download.open.fda.gov/drug/label/drug-label-0001-of-0013.json.zip',
size_mb: '128',
records: 20000,
}
assert.equal(
partZipPath('/app/storage/drug-data', partition),
'/app/storage/drug-data/drug-label-0001-of-0013.json.zip'
)
})
test('round-trips a recorded on-disk path (marker rebuild path)', ({ assert }) => {
// resolvePartSource feeds the recorded path back in as `file`; partZipPath
// must resolve to the same on-disk location.
const recorded = '/app/storage/drug-data/drug-label-0007-of-0013.json.zip'
const partition: DrugLabelPartition = {
display_name: 'part 7',
file: recorded,
size_mb: '0',
records: 0,
}
assert.equal(partZipPath('/app/storage/drug-data', partition), recorded)
})
})
// ─── parseDownloadState ───────────────────────────────────────────────────────
test.group('parseDownloadState', () => {
test('parses a valid marker JSON string', ({ assert }) => {
const raw = JSON.stringify({
export_date: '2026-06-06',
totalParts: 2,
totalRecords: 258914,
parts: [
{ index: 0, name: 'part 1', path: '/app/storage/drug-data/p1.zip', bytes: 100 },
{ index: 1, name: 'part 2', path: '/app/storage/drug-data/p2.zip', bytes: 200 },
],
completedAtMs: 1717718400000,
})
const marker = parseDownloadState(raw)
assert.isNotNull(marker)
assert.equal(marker!.export_date, '2026-06-06')
assert.equal(marker!.totalParts, 2)
assert.equal(marker!.totalRecords, 258914)
assert.lengthOf(marker!.parts, 2)
assert.equal(marker!.parts[1].path, '/app/storage/drug-data/p2.zip')
assert.equal(marker!.completedAtMs, 1717718400000)
})
test('defaults totalRecords to 0 for an older marker that predates the field', ({
assert,
}) => {
const raw = JSON.stringify({
export_date: '2026-06-06',
totalParts: 1,
parts: [{ index: 0, name: 'part 1', path: '/app/storage/drug-data/p1.zip', bytes: 100 }],
})
const marker = parseDownloadState(raw)
assert.isNotNull(marker)
assert.equal(marker!.totalRecords, 0)
})
test('returns null for a never-set (null) value', ({ assert }) => {
assert.isNull(parseDownloadState(null))
})
test('returns null for malformed JSON', ({ assert }) => {
assert.isNull(parseDownloadState('{not json'))
})
test('returns null when parts is empty', ({ assert }) => {
const raw = JSON.stringify({ export_date: '2026-06-06', totalParts: 0, parts: [] })
assert.isNull(parseDownloadState(raw))
})
test('returns null when a part is missing its path', ({ assert }) => {
const raw = JSON.stringify({
export_date: '2026-06-06',
totalParts: 1,
parts: [{ index: 0, name: 'part 1', bytes: 100 }],
})
assert.isNull(parseDownloadState(raw))
})
test('defaults index/name/bytes when absent on an otherwise-valid part', ({ assert }) => {
const raw = JSON.stringify({
export_date: '2026-06-06',
totalParts: 1,
parts: [{ path: '/app/storage/drug-data/p1.zip' }],
})
const marker = parseDownloadState(raw)
assert.isNotNull(marker)
assert.equal(marker!.parts[0].index, 0)
assert.equal(marker!.parts[0].name, '')
assert.equal(marker!.parts[0].bytes, 0)
})
})
// ─── deriveIngestPhase ────────────────────────────────────────────────────────
function dl(state: DrugDownloadStatus['state']): DrugDownloadStatus {
return { state, partsDone: 0, totalParts: 13, currentPartName: null }
}
function ing(state: DrugIngestPhaseStatus['state']): DrugIngestPhaseStatus {
return { state, records: 0, expectedTotal: 0, partsDone: 0, totalParts: 13, currentPartName: null }
}
test.group('deriveIngestPhase', () => {
test('idle when nothing has run and no rows exist', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('idle'), ing('idle'), 0), 'idle')
})
test('downloading when the download phase is running', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('running'), ing('idle'), 0), 'downloading')
})
test('downloaded when the download completed and ingest has not started', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('completed'), ing('idle'), 0), 'downloaded')
})
test('ingesting when the ingest phase is running', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('completed'), ing('running'), 0), 'ingesting')
})
test('ready when the ingest phase completed', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('completed'), ing('completed'), 250000), 'ready')
})
test('ready when idle but rows already exist from a past run', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('idle'), ing('idle'), 250000), 'ready')
})
test('failed when the ingest phase failed', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('completed'), ing('failed'), 0), 'failed')
})
test('failed when the download phase failed', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('failed'), ing('idle'), 0), 'failed')
})
// A failed download does NOT force ingest to fail: if ingest is running off
// already-downloaded parts, the running phase wins.
test('a running ingest outranks a sibling download failure', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('failed'), ing('running'), 0), 'ingesting')
})
// A failed ingest does NOT force download to fail: a running download wins.
test('a running download outranks a sibling ingest failure', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('running'), ing('failed'), 0), 'downloading')
})
// A completed download + failed ingest surfaces 'failed' (ingest is terminal,
// download isn't running) — the manual re-ingest path applies.
test('completed download with a failed ingest reports failed', ({ assert }) => {
assert.equal(deriveIngestPhase(dl('completed'), ing('failed'), 0), 'failed')
})
})
// ─── resolveExpectedTotal ─────────────────────────────────────────────────────
test.group('resolveExpectedTotal', () => {
test('prefers the real manifest total when known', ({ assert }) => {
assert.equal(resolveExpectedTotal(258914, 13, 5000), 258914)
})
// The regression: total_records 0 must NOT win via `?? fallback` (0 is not
// nullish). 0 means unknown → fall through to the parts-based estimate.
test('treats a manifest total of 0 as unknown and uses the parts estimate', ({ assert }) => {
assert.equal(resolveExpectedTotal(0, 13, 5000), 13 * 20000)
})
test('treats an undefined manifest total as unknown and uses the parts estimate', ({
assert,
}) => {
assert.equal(resolveExpectedTotal(undefined, 13, 5000), 13 * 20000)
})
test('falls back to the live row count when parts is also unknown', ({ assert }) => {
assert.equal(resolveExpectedTotal(0, 0, 5000), 5000)
assert.equal(resolveExpectedTotal(undefined, undefined, 5000), 5000)
})
test('honors a custom per-part estimate', ({ assert }) => {
assert.equal(resolveExpectedTotal(0, 4, 0, 25000), 4 * 25000)
})
})

View File

@ -0,0 +1,85 @@
import { test } from '@japa/runner'
import { parseInteractions, isSectionHeader, type InteractionBlock } from '../../util/drug_interactions.js'
// Representative real FDA `drug_interactions` text, two formats:
const MAXALT =
'7 DRUG INTERACTIONS 7.1 Propranolol The dose of MAXALT should be adjusted in ' +
'propranolol-treated patients, as propranolol has been shown to increase the plasma ' +
'AUC of rizatriptan by 70% [see Dosage and Administration (2.4) and Clinical Pharmacology (12.3)] . ' +
'7.2 Ergot-Containing Drugs Ergot-containing drugs have been reported to cause prolonged ' +
'vasospastic reactions. 7.3 Other 5-HT 1 Agonists Because their vasospastic effects may be ' +
'additive, co-administration of MAXALT and other 5-HT 1 agonists within 24 hours of each other ' +
'is contraindicated [see Contraindications (4)] . 7.5 Monoamine Oxidase Inhibitors MAXALT is ' +
'contraindicated in patients taking MAO-A inhibitors and non-selective MAO inhibitors.'
const ADVAIR =
'7 DRUG INTERACTIONS ADVAIR DISKUS has been used concomitantly with other drugs without ' +
'adverse drug reactions [see Clinical Pharmacology ( 12.2 )] . No formal drug interaction trials ' +
'have been performed with ADVAIR DISKUS . • Strong cytochrome P450 3A4 inhibitors (e.g., ritonavir, ' +
'ketoconazole): Use not recommended. ( 7.1 ) • Monoamine oxidase inhibitors and tricyclic ' +
'antidepressants: Use with extreme caution. ( 7.2 ) • Beta-blockers: Use with caution. ( 7.3 ) ' +
'7.1 Inhibitors of Cytochrome P450 3A4 Fluticasone propionate and salmeterol are substrates of CYP3A4.'
// Word sequence with bullet markers and whitespace normalized away — used to
// prove the parser never alters or drops FDA wording, only restructures it.
function words(s: string): string[] {
return s.replace(/•/g, ' ').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
}
function reconstruct(blocks: InteractionBlock[]): string {
return blocks
.map((b) => {
const parts: string[] = []
if (b.label) parts.push(b.label)
if (b.text) parts.push(b.text)
if (b.bullets) parts.push(b.bullets.join(' '))
return parts.join(' ')
})
.join(' ')
}
test.group('parseInteractions — content fidelity (safety)', () => {
test('preserves every word of numbered (MAXALT) text', ({ assert }) => {
assert.deepEqual(words(reconstruct(parseInteractions(MAXALT))), words(MAXALT))
})
test('preserves every word of bulleted (ADVAIR) text', ({ assert }) => {
assert.deepEqual(words(reconstruct(parseInteractions(ADVAIR))), words(ADVAIR))
})
})
test.group('parseInteractions — structure', () => {
test('splits numbered text into labeled subsections', ({ assert }) => {
const labels = parseInteractions(MAXALT)
.map((b) => b.label)
.filter((l): l is string => l !== null)
assert.deepEqual(labels, ['7.1', '7.2', '7.3', '7.5'])
})
test('does not split on parenthetical cross-references', ({ assert }) => {
// "(2.4)", "(12.3)", "(4)" must not become subsections.
const labels = parseInteractions(MAXALT).map((b) => b.label)
assert.notInclude(labels, '2.4')
assert.notInclude(labels, '12.3')
})
test('renders bulleted text as a bullet block', ({ assert }) => {
const blocks = parseInteractions(ADVAIR)
const bulletBlock = blocks.find((b) => b.bullets !== null)
assert.isNotNull(bulletBlock)
assert.isAbove(bulletBlock!.bullets!.length, 2)
// The real "7.1 Inhibitors..." subsection is still detected.
assert.include(blocks.map((b) => b.label), '7.1')
})
test('separates the section header from the body', ({ assert }) => {
const first = parseInteractions(MAXALT)[0]
assert.isTrue(isSectionHeader(first.text))
})
test('empty or null input yields no blocks', ({ assert }) => {
assert.deepEqual(parseInteractions(null), [])
assert.deepEqual(parseInteractions(''), [])
assert.deepEqual(parseInteractions(' '), [])
})
})

View File

@ -0,0 +1,352 @@
import { test } from '@japa/runner'
import {
mapDrugLabelRecord,
normalizeDrugName,
parseDrugLabelManifest,
type OpenFdaLabelRecord,
} from '../../util/drug_labels.js'
// ─── mapDrugLabelRecord ───────────────────────────────────────────────────────
test.group('mapDrugLabelRecord', () => {
// Case 1: Full Rx record → all fields mapped; sections flattened with \n\n.
test('maps a full Rx record with all fields present', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'abc-123',
id: 'rev-001',
version: '3',
effective_time: '20240115',
openfda: {
brand_name: ['Xarelto'],
generic_name: ['rivaroxaban'],
manufacturer_name: ['Janssen Pharmaceuticals, Inc.'],
product_ndc: ['50458-578-03'],
route: ['ORAL'],
product_type: ['HUMAN PRESCRIPTION DRUG'],
},
indications_and_usage: ['Indicated for treatment of DVT.', 'Also for PE.'],
dosage_and_administration: ['15 mg once daily with food.'],
warnings: ['Premature discontinuation increases risk of thrombotic events.'],
boxed_warning: ['PREMATURE DISCONTINUATION OF ANTICOAGULANTS MAY CAUSE THROMBOSIS.'],
drug_interactions: ['Avoid use with combined P-gp and CYP3A4 inhibitors.'],
contraindications: ['Active pathological bleeding.'],
when_using: [],
stop_use: [],
}
const row = mapDrugLabelRecord(record)
assert.isNotNull(row)
assert.equal(row!.set_id, 'abc-123')
assert.equal(row!.spl_id, 'rev-001')
assert.equal(row!.version, '3')
assert.equal(row!.brand_name, 'Xarelto')
assert.equal(row!.generic_name, 'rivaroxaban')
assert.equal(row!.manufacturer, 'Janssen Pharmaceuticals, Inc.')
assert.equal(row!.product_ndc, '50458-578-03')
assert.equal(row!.route, 'ORAL')
assert.equal(row!.product_type, 'HUMAN PRESCRIPTION DRUG')
assert.equal(row!.source_updated_at, '2024-01-15')
// Sections flattened with \n\n
assert.equal(row!.indications, 'Indicated for treatment of DVT.\n\nAlso for PE.')
assert.equal(row!.dosage, '15 mg once daily with food.')
assert.isNotNull(row!.boxed_warning)
assert.isNotNull(row!.drug_interactions)
assert.isNotNull(row!.contraindications)
// Empty arrays → null
assert.isNull(row!.when_using)
assert.isNull(row!.stop_use)
})
// Case 2: OTC record with when_using/stop_use, absent drug_interactions/contraindications/boxed_warning
test('maps an OTC record with absent Rx sections — no throw', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'otc-set-456',
openfda: {
brand_name: ['Tylenol'],
generic_name: ['acetaminophen'],
product_type: ['HUMAN OTC DRUG'],
route: ['ORAL'],
},
indications_and_usage: ['For temporary relief of minor pain.'],
when_using: ['Do not use with other acetaminophen products.'],
stop_use: ['Stop use if symptoms persist more than 3 days.'],
// drug_interactions, contraindications, boxed_warning intentionally absent
}
const row = mapDrugLabelRecord(record)
assert.isNotNull(row)
assert.isNull(row!.drug_interactions)
assert.isNull(row!.contraindications)
assert.isNull(row!.boxed_warning)
assert.isNotNull(row!.when_using)
assert.isNotNull(row!.stop_use)
assert.equal(row!.product_type, 'HUMAN OTC DRUG')
})
// Case 3: Missing set_id → returns null
test('returns null when set_id is missing', ({ assert }) => {
const record: OpenFdaLabelRecord = {
id: 'rev-xyz',
openfda: { brand_name: ['Advil'] },
}
assert.isNull(mapDrugLabelRecord(record))
})
test('returns null when set_id is empty string', ({ assert }) => {
const record: OpenFdaLabelRecord = { set_id: '', openfda: { brand_name: ['Advil'] } }
assert.isNull(mapDrugLabelRecord(record))
})
// Case 4: Multi-element generic_name/product_ndc/route → joined with ", "
test('joins multi-element generic_name, product_ndc, route with comma', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'multi-001',
openfda: {
generic_name: ['amoxicillin', 'clavulanate potassium'],
product_ndc: ['0069-0070-41', '0069-0070-83'],
route: ['ORAL', 'SUBLINGUAL'],
},
}
const row = mapDrugLabelRecord(record)
assert.isNotNull(row)
assert.equal(row!.generic_name, 'amoxicillin, clavulanate potassium')
assert.equal(row!.product_ndc, '0069-0070-41, 0069-0070-83')
assert.equal(row!.route, 'ORAL, SUBLINGUAL')
})
// Case 5: Multi-element brand_name → first element only
test('takes only the first element of brand_name', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'brand-multi-001',
openfda: { brand_name: ['Augmentin', 'Amoxicillin-Clavulanate'] },
}
const row = mapDrugLabelRecord(record)
assert.isNotNull(row)
assert.equal(row!.brand_name, 'Augmentin')
})
// Case 6: effective_time parsing
test('parses effective_time YYYYMMDD to YYYY-MM-DD', ({ assert }) => {
const record: OpenFdaLabelRecord = { set_id: 's1', effective_time: '20240115' }
assert.equal(mapDrugLabelRecord(record)!.source_updated_at, '2024-01-15')
})
test('returns null source_updated_at for garbage effective_time', ({ assert }) => {
const record: OpenFdaLabelRecord = { set_id: 's2', effective_time: 'garbage' }
assert.isNull(mapDrugLabelRecord(record)!.source_updated_at)
})
test('returns null source_updated_at when effective_time is missing', ({ assert }) => {
const record: OpenFdaLabelRecord = { set_id: 's3' }
assert.isNull(mapDrugLabelRecord(record)!.source_updated_at)
})
// Case 7: Empty-array section → null (not "")
test('empty section array returns null, not empty string', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 's4',
indications_and_usage: [],
warnings: [],
}
const row = mapDrugLabelRecord(record)!
assert.isNull(row.indications)
assert.isNull(row.warnings)
})
// Case 8: product_type handling
test('takes product_type from openfda array first element', ({ assert }) => {
const r1: OpenFdaLabelRecord = {
set_id: 's5',
openfda: { product_type: ['HUMAN OTC DRUG'] },
}
assert.equal(mapDrugLabelRecord(r1)!.product_type, 'HUMAN OTC DRUG')
const r2: OpenFdaLabelRecord = { set_id: 's6' }
assert.isNull(mapDrugLabelRecord(r2)!.product_type)
})
// Case 9: Section text with embedded whitespace → trimmed, joined properly
test('trims and joins sections with embedded whitespace', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 's7',
warnings: [' Risk of bleeding. ', '\n\nDo not crush.\n'],
}
const row = mapDrugLabelRecord(record)!
// The two elements are joined with \n\n, then the whole result trimmed.
// Individual elements may have internal whitespace but the join/trim
// removes leading/trailing blank lines from the whole blob.
assert.isNotNull(row.warnings)
assert.isFalse(row.warnings!.startsWith('\n'))
assert.isFalse(row.warnings!.endsWith('\n'))
assert.include(row.warnings!, ' Risk of bleeding. \n\n\n\nDo not crush.')
})
// Case 10: over-long set_id (> 64) → null (the idempotency key can't be truncated)
test('returns null when set_id exceeds the 64-char key width', ({ assert }) => {
const record: OpenFdaLabelRecord = {
set_id: 'x'.repeat(65),
openfda: { brand_name: ['Foo'] },
}
assert.isNull(mapDrugLabelRecord(record))
})
// Case 11: a generic_name join longer than the column width is clamped to fit
test('clamps an over-long generic_name to the column width (512)', ({ assert }) => {
// A combination product can join many active ingredients well past 512 chars.
const manyActives = Array.from({ length: 60 }, (_, i) => `ingredient-number-${i}`)
const record: OpenFdaLabelRecord = {
set_id: 'combo-1',
openfda: { brand_name: ['MegaVitamin'], generic_name: manyActives },
}
const row = mapDrugLabelRecord(record)!
assert.isNotNull(row.generic_name)
assert.isTrue(row.generic_name!.length <= 512)
assert.isTrue(row.searchable_name!.length <= 768)
})
})
// ─── normalizeDrugName ────────────────────────────────────────────────────────
test.group('normalizeDrugName', () => {
// Case 1
test('basic brand + generic combination', ({ assert }) => {
assert.equal(normalizeDrugName('Tylenol', 'acetaminophen'), 'tylenol acetaminophen')
})
// Case 2
test('multi-word brand + generic', ({ assert }) => {
assert.equal(
normalizeDrugName('Tylenol Extra Strength', 'acetaminophen'),
'tylenol extra strength acetaminophen'
)
})
// Case 3: Duplicate tokens deduped
test('deduplicates tokens preserving order', ({ assert }) => {
assert.equal(normalizeDrugName('Silicea', 'SILICEA'), 'silicea')
})
// Case 4: Punctuation stripped to spaces
test('strips punctuation to spaces', ({ assert }) => {
assert.equal(normalizeDrugName('Co-Q10 (50 mg)', null), 'co q10 50 mg')
})
// Case 5: Both null/empty → null
test('returns null when both inputs are null', ({ assert }) => {
assert.isNull(normalizeDrugName(null, null))
})
test('returns null when both inputs are empty strings', ({ assert }) => {
assert.isNull(normalizeDrugName('', ''))
})
// Case 6: Extra whitespace collapsed
test('collapses extra whitespace', ({ assert }) => {
assert.equal(normalizeDrugName(' Advil PM ', 'ibuprofen'), 'advil pm ibuprofen')
})
// Edge: one null
test('handles null brand with non-null generic', ({ assert }) => {
assert.equal(normalizeDrugName(null, 'ibuprofen'), 'ibuprofen')
})
test('handles non-null brand with null generic', ({ assert }) => {
assert.equal(normalizeDrugName('Advil', null), 'advil')
})
})
// ─── parseDrugLabelManifest ───────────────────────────────────────────────────
test.group('parseDrugLabelManifest', () => {
const wellFormed = {
meta: { disclaimer: 'Do not rely...' },
results: {
drug: {
label: {
export_date: '2026-06-06',
total_records: 258914,
partitions: [
{
display_name: '/drug/label (part 1 of 13)',
file: 'https://download.open.fda.gov/drug/label/drug-label-0001-of-0013.json.zip',
size_mb: '128.11',
records: 20000,
},
{
display_name: '/drug/label (part 2 of 13)',
file: 'https://download.open.fda.gov/drug/label/drug-label-0002-of-0013.json.zip',
size_mb: '143.09',
records: 20000,
},
],
},
},
},
}
// Well-formed → typed object
test('parses a well-formed manifest into a typed object', ({ assert }) => {
const result = parseDrugLabelManifest(wellFormed)
assert.equal(result.export_date, '2026-06-06')
assert.equal(result.total_records, 258914)
assert.lengthOf(result.partitions, 2)
assert.equal(
result.partitions[0].file,
'https://download.open.fda.gov/drug/label/drug-label-0001-of-0013.json.zip'
)
assert.equal(result.partitions[0].records, 20000)
assert.equal(result.partitions[1].size_mb, '143.09')
})
// Missing results.drug.label → throws
test('throws when results.drug.label is missing', ({ assert }) => {
const bad = { results: { drug: {} } }
assert.throws(() => parseDrugLabelManifest(bad), /Unexpected FDA manifest format/)
})
test('throws when results is missing entirely', ({ assert }) => {
assert.throws(() => parseDrugLabelManifest({}), /Unexpected FDA manifest format/)
})
// Partition missing file → skipped
test('skips partitions that are missing the file field', ({ assert }) => {
const partial = {
results: {
drug: {
label: {
export_date: '2026-06-06',
total_records: 100,
partitions: [
{ display_name: 'bad', size_mb: '10', records: 5 }, // no file
{
display_name: 'good',
file: 'https://example.com/part.zip',
size_mb: '10',
records: 5,
},
],
},
},
},
}
const result = parseDrugLabelManifest(partial)
assert.lengthOf(result.partitions, 1)
assert.equal(result.partitions[0].file, 'https://example.com/part.zip')
})
// All partitions invalid → throws
test('throws when all partitions lack a file field', ({ assert }) => {
const allBad = {
results: {
drug: {
label: {
export_date: '2026-06-06',
total_records: 100,
partitions: [{ display_name: 'bad', records: 5 }],
},
},
},
}
assert.throws(() => parseDrugLabelManifest(allBad), /all partitions were invalid/)
})
})

View File

@ -4,5 +4,5 @@
"rootDir": "./",
"outDir": "./build"
},
"exclude": ["./inertia/**/*", "node_modules", "build"]
"exclude": ["./inertia/**/*", "tests/standalone/**/*", "node_modules", "build"]
}

View File

@ -5,6 +5,12 @@ export type SpecResource = {
description: string
url: string
size_mb: number
/**
* Resource-type discriminator. Absent == 'zim' so every existing manifest
* entry keeps the current ZIM download path. 'dataset' routes the tier
* installer to the DB-ingested drug pipeline instead.
*/
type?: 'zim' | 'dataset'
}
export type SpecTier = {
@ -69,6 +75,11 @@ export type CategoryWithStatus = SpecCategory & {
// picked something larger and downloads are still running. Lets the UI show
// the user's actual intent during the (often long) download window.
downloadingTierSlug?: string
// True when the in-flight resource for `downloadingTierSlug` is a DB-ingested
// dataset (the FDA drug labels) whose download has finished and the heavy
// ingest is running — so the card can flip "(downloading)" → "(indexing)" at
// the handoff instead of looking stuck. Only meaningful with downloadingTierSlug.
downloadingTierIndexing?: boolean
}
export type CollectionWithStatus = SpecCollection & {

130
admin/types/conditions.ts Normal file
View File

@ -0,0 +1,130 @@
/**
* "When to use what" condition-first reference types (Phase 1 + Phase 2).
*
* A condition (situation) is a curated first-aid / emergency scenario the user
* browses or searches. Each carries `searchTerms` (synonyms) that drive the
* FULLTEXT search over `drug_labels.indications` the same machinery the
* indication FULLTEXT search uses.
*
* Phase 1 maps conditions OTC drugs.
* Phase 2 adds natural remedies from NCCIH, resolved against the same condition
* spine (in-memory, no DB table).
*
* All server client transfer shapes for this feature live in this file.
*/
import type { DrugSearchResult } from './drug_reference.js'
// ─── Curated condition spine ──────────────────────────────────────────────────
/**
* One curated condition/situation.
*
* - `slug` URL-safe stable id (e.g. "burns"). The detail route key.
* - `label` Human-facing name (e.g. "Burns").
* - `category` Grouping for the browse grid (e.g. "Skin & wounds").
* - `searchTerms` Synonyms expanded into the FULLTEXT query (e.g.
* ["burn", "scald", "sunburn"]). Curation quality drives
* result quality, so these are hand-tuned, not generated.
*/
export interface Condition {
slug: string
label: string
category: string
searchTerms: string[]
}
/**
* The versioned condition-spine file shape. `version` lets the curated list
* evolve without a migration (the taxonomy is data, not schema), mirroring the
* `spec_version` field on collections/kiwix-categories.json.
*/
export interface ConditionsFile {
version: string
conditions: Condition[]
}
// ─── Natural remedies (Phase 2) ───────────────────────────────────────────────
/**
* One curated natural / herbal remedy from NCCIH "Herbs at a Glance".
*
* - `slug` URL-safe stable id (e.g. "ginger"). Used for dedup.
* - `name` Human-facing name (e.g. "Ginger").
* - `commonNames` Botanical / alternate names (e.g. ["Zingiber officinale"]).
* - `conditions` Curated mapping to condition slugs. The join key a remedy
* surfaces for a condition if its slug appears here.
* - `uses` Plain-language summary of traditional / common use.
* - `evidence` NCCIH evidence tone ("limited/mixed evidence" etc.).
* - `cautions` Key safety notes, interactions, who should avoid.
* - `sourceUrl` Canonical NCCIH fact-sheet URL for the "Learn more" link.
*/
export interface NaturalRemedy {
slug: string
name: string
commonNames: string[]
conditions: string[]
uses: string
/**
* Practical offline instructions ("how to do/prepare it") sourced from the
* same cited page. Optional absent when the source gives no actionable
* how-to (we never invent dosages or steps). The offline-mission field: the
* sourceUrl link is dead without internet, so the card must carry the how.
*/
how?: string
evidence: string
cautions: string
sourceUrl: string
/**
* Which curated corpus the entry came from. 'herb' = NCCIH herbal fact
* sheets; 'self-care' = non-herbal home-care measures from CDC/NIH/FDA pages
* (the non-herbal home-care entries). Assigned at merge time in
* condition_service the JSON files don't carry it. Optional so older data
* parses; absent means 'herb'.
*/
kind?: 'herb' | 'self-care'
}
/**
* The versioned natural-remedies file shape. Mirrors the ConditionsFile
* convention `version` tracks the curation date; `source` carries the
* public-domain credit required by NCCIH's terms.
*/
export interface NaturalRemediesFile {
version: string
source: {
name: string
url: string
license: string
}
remedies: NaturalRemedy[]
}
// ─── DTOs (server → client) ───────────────────────────────────────────────────
/**
* Slim condition shape for the browse grid omits `searchTerms` (a
* server-only search-implementation detail the client never needs).
*/
export interface ConditionSummary {
slug: string
label: string
category: string
}
/**
* Result of resolving a condition (by slug or free text) to matching OTC drugs
* and natural remedies (Phase 2).
*
* - `condition` is the matched curated condition when resolving by slug, or a
* synthetic summary echoing the free-text query when off-list.
* - `drugs` reuses the Drug Reference collapsed search result shape so the
* existing DrugResultRow renders them unchanged.
* - `remedies` is the Phase-2 natural-remedy list empty array when none match
* (never undefined so client code can always iterate safely).
*/
export interface ConditionDrugsResult {
condition: ConditionSummary
drugs: DrugSearchResult[]
remedies: NaturalRemedy[]
}

View File

@ -0,0 +1,256 @@
/**
* Drug Reference v1 types.
*
* Enums, DTOs, and manifest types for the openFDA drug-label feature.
* All server client data transfer shapes are defined here.
*/
// ─── Product type ─────────────────────────────────────────────────────────────
export const PRODUCT_TYPES = {
OTC: 'HUMAN OTC DRUG',
RX: 'HUMAN PRESCRIPTION DRUG',
} as const
export type ProductType = (typeof PRODUCT_TYPES)[keyof typeof PRODUCT_TYPES]
// ─── Manifest (from api.fda.gov/download.json) ────────────────────────────────
export interface DrugLabelPartition {
display_name: string
file: string // full URL to the .zip
size_mb: string // string in the JSON
records: number
}
export interface DrugLabelManifest {
export_date: string
total_records: number
partitions: DrugLabelPartition[]
}
// ─── Download-state marker (persisted in KV, no migration) ────────────────────
/**
* One downloaded part recorded in the `drugReference.downloadState` KV marker.
* `index` is the manifest partition position so a manual ingest with no manifest
* in its params can rebuild the ordered part list (and resolve each on-disk zip
* via partZipPath) without re-fetching the manifest from the network.
*/
export interface DownloadStatePart {
index: number
name: string
path: string
bytes: number
}
/**
* The download-phase completion marker. Written to KV after the LAST part lands
* on disk; read by the ingest job + the service to know parts are available for
* the manual "Ingest into search" path and to gate POST /ingest. Cleared after a
* full ingest succeeds (when the parts are deleted).
*/
export interface DownloadStateMarker {
export_date: string
totalParts: number
/**
* Manifest `total_records` (~259k), persisted so a manual or auto-chained
* ingest that rebuilds the manifest from this marker (rather than carrying it
* in job data) still knows the real label-count denominator the source of
* the "X of ~259k labels" counter, the records-based progress %, and the ETA.
* Older markers written before this field exists parse back as 0 (unknown).
*/
totalRecords: number
parts: DownloadStatePart[]
completedAtMs: number
}
// ─── Install-state metadata (tier-installer → InstalledResource row) ──────────
/**
* Identity an install writes to the `installed_resources` table when the drug
* dataset reaches `ready`, so the curated-tier "installed" math (which is row-
* driven for ZIM/map) recognizes the dataset uniformly. Threaded from the tier
* installer (the manifest `dataset` resource) through the download job into the
* ingest job, which writes the row. Absent on a manual download (the standalone
* "Download FDA data" path), in which case no row is written the install-state
* row only exists when the install came through a curated tier.
*
* `version` is filled with the dataset's openFDA `export_date` at write time
* (the real freshness key), NOT the manifest's placeholder `version` string.
*/
export interface DrugDatasetResourceMeta {
resourceId: string
/** Manifest `version` placeholder; the row's real version is the export_date. */
version: string
collectionRef: string | null
}
// ─── Job params ───────────────────────────────────────────────────────────────
/**
* DownloadDrugDataJob params. Pass 0 fetches the manifest; each later pass
* downloads one part to disk. `phase` was previously written into job data but
* never typed it is part of the contract now.
*/
export interface DownloadDrugDataJobParams {
partIndex?: number
manifest?: DrugLabelManifest
totalParts?: number
/** Auto-dispatch the ingest phase after the last part downloads. Default true. */
autoChain?: boolean
/** Epoch ms when the download began (set on pass 0, carried through continuations). */
startedAt?: number
/** Bytes downloaded for the current part (live progress). */
bytesDownloaded?: number
currentPartName?: string | null
phase?: 'manifest' | 'downloading' | 'downloaded' | 'failed'
/**
* Install-state identity, present only when the install came through a curated
* tier. Carried through the continuation chain and handed to the ingest job so
* it can write the `installed_resources` row on `ready`.
*/
resourceMeta?: DrugDatasetResourceMeta
}
/**
* IngestDrugDataJob params. Each pass reads one on-disk part and streams it into
* drug_labels. Zero network I/O. `manifest` is optional: a manual ingest can run
* from the KV download-state marker alone.
*/
export interface IngestDrugDataJobParams {
partIndex?: number
manifest?: DrugLabelManifest
totalParts?: number
recordsIngested?: number
recordsSkipped?: number
/** Epoch ms when the ingest began (set on pass 0, carried through continuations). */
startedAt?: number
/**
* drug_labels row count when this ingest RUN began (set on pass 0, carried
* through continuations). Progress baseline: on a re-ingest into a full table
* the raw row count reads ~100% from second zero, so this-run progress is
* driven by jobRecords vs max(0, rowCount - startRowCount) instead.
*/
startRowCount?: number
currentPartName?: string | null
phase?: 'ingesting' | 'ready' | 'failed'
/**
* Install-state identity, forwarded from the download job when the install
* came through a curated tier. The ingest job writes the `installed_resources`
* row on `ready` using this. Absent on a manual ingest.
*/
resourceMeta?: DrugDatasetResourceMeta
}
// ─── Search result DTO (collapsed by brand+generic) ──────────────────────────
/**
* Slim result row one per distinct (brand_name, generic_name) pair.
* `id` is a representative row id for the detail view; `labelCount` tells
* the UI how many individual FDA set_ids collapsed into this result.
*/
export interface DrugSearchResult {
id: number
brand_name: string | null
generic_name: string | null
manufacturer: string | null
route: string | null
product_type: string | null
labelCount: number
}
// ─── Detail DTO (full label body) ─────────────────────────────────────────────
export interface DrugLabelDetail {
id: number
set_id: string
spl_id: string | null
version: string | null
brand_name: string | null
generic_name: string | null
manufacturer: string | null
product_ndc: string | null
route: string | null
product_type: string | null
indications: string | null
dosage: string | null
warnings: string | null
boxed_warning: string | null
drug_interactions: string | null
contraindications: string | null
when_using: string | null
stop_use: string | null
source_updated_at: string | null
ingested_at: string
}
// ─── Interaction comparison DTO ───────────────────────────────────────────────
/**
* Slim DTO for the side-by-side interaction comparison view.
* Contains only the fields needed to render one column: identity + label text.
*/
export interface DrugInteractionEntry {
id: number
brand_name: string | null
generic_name: string | null
product_type: string | null
drug_interactions: string | null
}
// ─── Ingest status (two-phase) ────────────────────────────────────────────────
/**
* Top-level state machine across both phases. `downloaded` means parts are on
* disk and ingest hasn't started/finished the manual "Ingest into search"
* button is available. `ready` means a full ingest succeeded (parts deleted).
*/
export type DrugIngestPhase =
| 'idle'
| 'downloading'
| 'downloaded'
| 'ingesting'
| 'ready'
| 'failed'
/** Per-phase run state. */
export type DrugPhaseState = 'idle' | 'running' | 'completed' | 'failed'
/** Download-phase sub-status. */
export interface DrugDownloadStatus {
state: DrugPhaseState
partsDone: number
totalParts: number
bytesDownloaded?: number
bytesTotal?: number
currentPartName: string | null
failedReason?: string
}
/** Ingest-phase sub-status. */
export interface DrugIngestPhaseStatus {
state: DrugPhaseState
records: number
/** Approx. total records from the manifest (0 if not known yet). Drives the counter + %. */
expectedTotal: number
partsDone: number
totalParts: number
currentPartName: string | null
failedReason?: string
}
/**
* The combined two-phase status DTO returned to the client. `phase` is the
* top-level state machine derived from the two sub-phases + rowCount.
*/
export interface DrugIngestStatus {
phase: DrugIngestPhase
download: DrugDownloadStatus
ingest: DrugIngestPhaseStatus
/** Epoch ms the active phase began, for live elapsed + a rough ETA (null if idle). */
startedAtMs: number | null
lastUpdated: string | null
rowCount: number
error?: string
}

View File

@ -42,6 +42,27 @@ export const KV_STORE_SCHEMA = {
'ai.amdHsaOverride': 'string',
'ai.autoFixGpuPassthrough': 'boolean',
'gpu.autoRemediatedAt': 'string',
// Drug Reference v1 — export_date of the last successfully completed
// openFDA drug-label ingest (e.g. "2026-06-06"). Written by
// IngestDrugDataJob on final-part completion; read by the search page's
// status panel to show "Last updated: <date>". Null when never ingested.
'drugReference.lastUpdatedExportDate': 'string',
// Drug Reference — two-step ingest download-state marker (no migration; status
// lives in job data + this KV key). Written by DownloadDrugDataJob after the
// LAST part lands on disk; a JSON string of DownloadStateMarker
// ({ export_date, totalParts, parts: [{ index, name, path, bytes }],
// completedAtMs }). Read by IngestDrugDataJob to rebuild the part list for a
// manual "Ingest into search" run (no manifest, no re-download) and by the
// service to gate POST /ingest. Parsed defensively (parseDownloadState) with a
// null fallback — the key simply doesn't exist before the first download.
// Cleared after a full ingest succeeds (when the on-disk parts are deleted).
'drugReference.downloadState': 'string',
// Drug Reference — affirmative-content gate (upstream #1040). Independent of
// the tier install: installing `medicine-standard` lights up the verbatim FDA
// label search and condition→OTC matching, but the hand-authored self-care and
// herbal REMEDY sections stay hidden until this flips true. Defaults off
// (null → false); flipped on after a clinician content-pass, not user-toggled.
'drugReference.remediesEnabled': 'boolean',
} as const
type KVTagToType<T extends string> = T extends 'boolean' ? boolean : string

55
admin/util/compare_ids.ts Normal file
View File

@ -0,0 +1,55 @@
/**
* Drug Reference comparison id-list utilities.
*
* Pure module (no Lucid / HTTP imports) so it can be unit-tested without
* booting the AdonisJS container. Mirrors the file_classification.ts pattern.
*/
/** Maximum number of drugs that can be compared side-by-side. */
export const MAX_COMPARE = 5
/**
* Parse a raw comma-separated id string into a validated, deduped,
* capped list of positive integer ids.
*
* Rules (in order):
* 1. Split on commas.
* 2. Trim whitespace from each segment.
* 3. Parse each segment as a base-10 integer.
* 4. Drop any result that is not a finite integer > 0.
* 5. Dedupe, preserving first-occurrence order.
* 6. Cap the list at MAX_COMPARE entries; extras are silently dropped.
*
* Returns [] for an empty/entirely-invalid input.
*
* @example
* parseCompareIds('1,2,3') // [1, 2, 3]
* parseCompareIds('2,1,2,3,4,5,6') // [2, 1, 3, 4, 5] (deduped, capped at 5)
* parseCompareIds('0,-1,abc,') // []
* parseCompareIds(' 3 , 1 ') // [3, 1]
*/
export function parseCompareIds(raw: string): number[] {
if (!raw || raw.trim() === '') return []
const seen = new Set<number>()
const result: number[] = []
for (const segment of raw.split(',')) {
if (result.length >= MAX_COMPARE) break
const trimmed = segment.trim()
if (trimmed === '') continue
const n = Number(trimmed)
// Must be a finite integer greater than zero.
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) continue
if (seen.has(n)) continue
seen.add(n)
result.push(n)
}
return result
}

356
admin/util/conditions.ts Normal file
View File

@ -0,0 +1,356 @@
/**
* "When to use what" pure, unit-testable helpers (Phase 1 + Phase 2).
*
* NO Lucid / AdonisJS / HTTP imports. These take plain objects and return plain
* objects so they run standalone under `node --experimental-strip-types` without
* booting MySQL or Redis. Mirrors the `drug_labels.ts` helper convention.
*/
import type {
Condition,
ConditionsFile,
ConditionSummary,
NaturalRemedy,
NaturalRemediesFile,
} from '../types/conditions.js'
import type { DrugSearchResult } from '../types/drug_reference.js'
/**
* Canonical FDA product_type strings, mirrored from `PRODUCT_TYPES` in
* types/drug_reference.ts. Inlined here (not imported) so this module stays a
* pure, value-import-free helper runnable under `node --experimental-strip-types`
* (which does not rewrite `.js` specifiers to `.ts` for runtime value imports).
* The standalone test asserts these equal PRODUCT_TYPES.OTC / .RX, so they
* cannot drift from the canonical source.
*/
export const OTC_PRODUCT_TYPE = 'HUMAN OTC DRUG'
export const RX_PRODUCT_TYPE = 'HUMAN PRESCRIPTION DRUG'
// ─── Spine parsing (fail-soft) ────────────────────────────────────────────────
/** Trim a value to a non-empty string, or return null. */
function nonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
/**
* Validate + coerce one raw condition entry into a typed Condition.
*
* Returns null when the entry is structurally unusable (missing slug/label/
* category, or no usable searchTerms) so the caller can skip it rather than
* surface a broken row. searchTerms are de-duped (case-insensitive) and
* stripped of empties; an entry with zero usable terms is dropped (it could
* never match anything).
*/
export function parseConditionEntry(raw: unknown): Condition | null {
if (typeof raw !== 'object' || raw === null) return null
const r = raw as Record<string, unknown>
const slug = nonEmptyString(r.slug)
const label = nonEmptyString(r.label)
const category = nonEmptyString(r.category)
if (!slug || !label || !category) return null
if (!Array.isArray(r.searchTerms)) return null
const seen = new Set<string>()
const searchTerms: string[] = []
for (const term of r.searchTerms) {
const clean = nonEmptyString(term)
if (!clean) continue
const key = clean.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
searchTerms.push(clean)
}
if (searchTerms.length === 0) return null
return { slug, label, category, searchTerms }
}
/**
* Defensively parse a ConditionsFile-shaped object.
*
* A non-object, a missing/empty `conditions` array, or every entry being
* malformed all yield an empty `conditions` list (with a best-effort version)
* rather than throwing the page then degrades to free-text-only search
* instead of crashing. Duplicate slugs keep the FIRST occurrence (the curated
* order is intentional).
*/
export function parseConditionsFile(json: unknown): ConditionsFile {
if (typeof json !== 'object' || json === null) {
return { version: 'unknown', conditions: [] }
}
const root = json as Record<string, unknown>
const version = nonEmptyString(root.version) ?? 'unknown'
if (!Array.isArray(root.conditions)) {
return { version, conditions: [] }
}
const seenSlugs = new Set<string>()
const conditions: Condition[] = []
for (const raw of root.conditions) {
const entry = parseConditionEntry(raw)
if (!entry) continue
if (seenSlugs.has(entry.slug)) continue
seenSlugs.add(entry.slug)
conditions.push(entry)
}
return { version, conditions }
}
// ─── Lookup + projection ──────────────────────────────────────────────────────
/** Find a condition by its slug (exact match). Returns null when absent. */
export function findConditionBySlug(conditions: Condition[], slug: string): Condition | null {
const target = slug.trim()
if (!target) return null
return conditions.find((c) => c.slug === target) ?? null
}
/** Project a Condition to the client-facing summary (drops searchTerms). */
export function toConditionSummary(condition: Condition): ConditionSummary {
return {
slug: condition.slug,
label: condition.label,
category: condition.category,
}
}
// ─── Reverse link: indications text → curated situations ──────────────────────
/**
* The other direction of the symbiotic relationship: given a single drug's
* indications-and-usage text, find which curated situations it treats.
*
* A condition matches when ANY of its searchTerms appears as a case-insensitive
* substring of the indications text. Substring (not word-boundary or FULLTEXT)
* matching mirrors the FULLTEXT/LIKE intent of the forward search closely enough
* for a "Commonly used for" affordance, while staying a pure string operation
* with no DB. Curated order is preserved and results are deduped by slug.
*
* Blank/empty input [] (a label with no indications treats nothing here).
*/
export function situationsForIndications(
indicationsText: string | null | undefined,
conditions: Condition[]
): ConditionSummary[] {
if (typeof indicationsText !== 'string') return []
const haystack = indicationsText.toLowerCase()
if (haystack.trim().length === 0) return []
const matched: ConditionSummary[] = []
const seen = new Set<string>()
for (const condition of conditions) {
if (seen.has(condition.slug)) continue
const hit = condition.searchTerms.some((term) => {
const needle = term.trim().toLowerCase()
return needle.length > 0 && haystack.includes(needle)
})
if (hit) {
seen.add(condition.slug)
matched.push(toConditionSummary(condition))
}
}
return matched
}
// ─── FULLTEXT query construction ──────────────────────────────────────────────
/**
* Build a MySQL NATURAL LANGUAGE MODE query string from a condition's
* searchTerms.
*
* - Multi-word terms are wrapped in double quotes so they match as a phrase
* ("sore throat") rather than as two loose tokens this trades a little
* recall for precision on multi-word conditions.
* - Single-word terms are passed through bare.
* - Internal double-quotes inside a term are stripped (they would break the
* phrase quoting).
* - Terms are de-duped (case-insensitive) and empties dropped. An all-empty
* input yields '' so the caller can short-circuit to the LIKE fallback.
*
* NATURAL LANGUAGE MODE treats the space-joined terms as an OR-ish relevance
* query, which is what we want: any synonym can surface a matching label, ranked
* by relevance.
*/
export function buildIndicationQuery(searchTerms: string[]): string {
const seen = new Set<string>()
const parts: string[] = []
for (const raw of searchTerms) {
if (typeof raw !== 'string') continue
const term = raw.trim().replace(/"/g, '')
if (term.length === 0) continue
const key = term.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
parts.push(/\s/.test(term) ? `"${term}"` : term)
}
return parts.join(' ')
}
// ─── Natural remedies (Phase 2) — parsing + lookup ───────────────────────────
/**
* Validate + coerce one raw remedy entry into a typed NaturalRemedy.
*
* Returns null when the entry is structurally unusable (missing required string
* fields or conditions array) so the caller can skip it rather than surface a
* broken row. Mirrors the defensive posture of parseConditionEntry.
*/
export function parseRemedyEntry(raw: unknown): NaturalRemedy | null {
if (typeof raw !== 'object' || raw === null) return null
const r = raw as Record<string, unknown>
const slug = nonEmptyString(r.slug)
const name = nonEmptyString(r.name)
const uses = nonEmptyString(r.uses)
const evidence = nonEmptyString(r.evidence)
const cautions = nonEmptyString(r.cautions)
const sourceUrl = nonEmptyString(r.sourceUrl)
if (!slug || !name || !uses || !evidence || !cautions || !sourceUrl) return null
if (!Array.isArray(r.commonNames)) return null
const commonNames: string[] = []
for (const cn of r.commonNames) {
const clean = nonEmptyString(cn)
if (clean) commonNames.push(clean)
}
if (!Array.isArray(r.conditions)) return null
const conditions: string[] = []
for (const c of r.conditions) {
const clean = nonEmptyString(c)
if (clean) conditions.push(clean)
}
// A remedy with no conditions never surfaces — drop it.
if (conditions.length === 0) return null
// Optional practical how-to (the offline-mission field) — carried when present.
const how = nonEmptyString(r.how)
return how
? { slug, name, commonNames, conditions, uses, how, evidence, cautions, sourceUrl }
: { slug, name, commonNames, conditions, uses, evidence, cautions, sourceUrl }
}
/**
* Defensively parse a NaturalRemediesFile-shaped object.
*
* A non-object, missing/empty `remedies` array, or all malformed entries yield
* an empty `remedies` list (with a best-effort version) rather than throwing
* the UI degrades to "no natural remedies for this condition" instead of
* crashing. Duplicate remedy slugs keep the FIRST occurrence.
*/
export function parseNaturalRemediesFile(json: unknown): NaturalRemediesFile {
const empty: NaturalRemediesFile = {
version: 'unknown',
source: { name: '', url: '', license: '' },
remedies: [],
}
if (typeof json !== 'object' || json === null) return empty
const root = json as Record<string, unknown>
const version = nonEmptyString(root.version) ?? 'unknown'
let source = { name: '', url: '', license: '' }
if (typeof root.source === 'object' && root.source !== null) {
const s = root.source as Record<string, unknown>
source = {
name: nonEmptyString(s.name) ?? '',
url: nonEmptyString(s.url) ?? '',
license: nonEmptyString(s.license) ?? '',
}
}
if (!Array.isArray(root.remedies)) return { version, source, remedies: [] }
const seenSlugs = new Set<string>()
const remedies: NaturalRemedy[] = []
for (const raw of root.remedies) {
const entry = parseRemedyEntry(raw)
if (!entry) continue
if (seenSlugs.has(entry.slug)) continue
seenSlugs.add(entry.slug)
remedies.push(entry)
}
return { version, source, remedies }
}
/**
* Return all remedies whose `conditions` array includes the given slug.
*
* Stable order (preserves the curated order of the remedies array). Pure O(remedies)
* filter no DB, no fuzzy matching. Returns [] when nothing matches or when file
* has no remedies.
*/
export function remediesForCondition(file: NaturalRemediesFile, slug: string): NaturalRemedy[] {
const target = slug.trim()
if (!target) return []
return file.remedies.filter((r) => r.conditions.includes(target))
}
/**
* Short, display-friendly source attribution for a remedy plain text, NOT a
* link. NOMAD is offline-first: remedy cards carry everything needed to act and
* never link out to the internet; this credit satisfies the public-domain
* attribution without implying connectivity.
*/
export function remedySourceName(remedy: { kind?: 'herb' | 'self-care'; sourceUrl: string }): string {
if ((remedy.kind ?? 'herb') === 'herb') return 'NCCIH'
const url = remedy.sourceUrl.toLowerCase()
if (url.includes('cdc.gov')) return 'CDC'
if (url.includes('fda.gov')) return 'FDA'
if (url.includes('medlineplus.gov')) return 'MedlinePlus (NLM)'
if (url.includes('nih.gov')) return 'NIH'
return 'US government source'
}
/**
* Return all remedies matching a free-text query as a secondary fallback.
*
* Case-insensitive substring match over `name` and `uses`. Used when a free-text
* query does not resolve to a curated condition slug. Deduped by slug.
*/
export function remediesForFreeText(file: NaturalRemediesFile, query: string): NaturalRemedy[] {
const q = query.trim().toLowerCase()
if (!q) return []
const seen = new Set<string>()
const results: NaturalRemedy[] = []
for (const r of file.remedies) {
if (seen.has(r.slug)) continue
if (r.name.toLowerCase().includes(q) || r.uses.toLowerCase().includes(q)) {
seen.add(r.slug)
results.push(r)
}
}
return results
}
// ─── Result ordering ──────────────────────────────────────────────────────────
/**
* Stable-sort drug results OTC-first.
*
* The condition browser is consumer-facing, so over-the-counter products are
* the most actionable and lead. Order within each tier (OTC other Rx) is
* preserved from the input (which the service already relevance-ranks), so this
* only re-buckets by product_type without disturbing relevance order.
*
* Bucketing: OTC = 0, anything not Rx-and-not-OTC = 1, Rx = 2. Returns a new
* array; the input is not mutated.
*/
export function orderOtcFirst(drugs: DrugSearchResult[]): DrugSearchResult[] {
const rank = (d: DrugSearchResult): number => {
if (d.product_type === OTC_PRODUCT_TYPE) return 0
if (d.product_type === RX_PRODUCT_TYPE) return 2
return 1
}
return drugs
.map((d, i) => ({ d, i }))
.sort((a, b) => rank(a.d) - rank(b.d) || a.i - b.i)
.map((x) => x.d)
}

View File

@ -0,0 +1,116 @@
/**
* Parse a flattened FDA label section (drug interactions, dosage, warnings,
* contraindications, indications, ...) into ordered blocks for readable
* rendering. The split is purely structural: every word of the original text is
* preserved in order only the numbered-subsection and bullet markers move into
* block metadata. No FDA wording is altered, summarized, or dropped. The
* content-fidelity invariant is enforced by the unit tests.
*/
/** One renderable block of parsed FDA label text. */
export interface LabelBlock {
/** Subsection label like "7.1", or null for intro/header text. */
label: string | null
/** Verbatim text (subsection label stripped from the front), or null for a bullet block. */
text: string | null
/** Verbatim bullet items, or null for a text block. */
bullets: string[] | null
}
/** Backward-compatible alias — the interaction comparison view's original name. */
export type InteractionBlock = LabelBlock
// A section's own leading header: a number then an ALL-CAPS section name, e.g.
// "7 DRUG INTERACTIONS", "2 DOSAGE AND ADMINISTRATION". Anchoring subsection
// detection to THIS section's number means inline cross-refs like "(12.3)" or
// "( 7.1 )" are never mistaken for a subsection heading.
const HEADER_RE = /^\s*(\d{1,2})\s+[A-Z][A-Z][A-Z &,/()'.-]*/
// "<major>.<n> <Capital><lowercase>" subsection number, for the no-header fallback.
const SUBSECTION_RE = /\b(\d{1,2})\.\d{1,2}\s+[A-Z][a-z]/g
/** This section's major number — from its leading header, else the dominant subsection major. */
function detectMajor(text: string): string | null {
const header = text.match(HEADER_RE)
if (header) return header[1]
const counts = new Map<string, number>()
for (const m of text.matchAll(SUBSECTION_RE)) {
counts.set(m[1], (counts.get(m[1]) ?? 0) + 1)
}
let best: string | null = null
let bestN = 0
for (const [major, n] of counts) {
if (n > bestN) {
best = major
bestN = n
}
}
return best
}
export function parseLabelSection(raw: string | null | undefined): LabelBlock[] {
if (!raw) return []
const text = raw.trim()
if (!text) return []
const major = detectMajor(text)
let pieces: string[]
let labelRe: RegExp | null = null
if (major) {
// Split before "<major>.<n> <Capital><lowercase>". The \b prevents matching
// inside a larger number (e.g. "17.1"); the [A-Z][a-z] requirement excludes
// parenthetical refs like "( 7.1 )" (those are followed by ")"). Lookahead
// only — no lookbehind — for broad browser support.
const splitRe = new RegExp(`(?=\\b${major}\\.\\d{1,2}\\s+[A-Z][a-z])`)
pieces = text.split(splitRe)
labelRe = new RegExp(`^(${major}\\.\\d{1,2})\\s+`)
} else {
pieces = [text]
}
const blocks: LabelBlock[] = []
for (const rawPiece of pieces) {
const piece = rawPiece.trim()
if (!piece) continue
let label: string | null = null
let body = piece
if (labelRe) {
const m = piece.match(labelRe)
if (m) {
label = m[1]
body = piece.slice(m[0].length).trim()
}
}
if (body.includes('•')) {
const parts = body.split('•')
const lead = parts[0].trim()
const items = parts
.slice(1)
.map((s) => s.trim())
.filter(Boolean)
if (lead) {
blocks.push({ label, text: lead, bullets: null })
if (items.length) blocks.push({ label: null, text: null, bullets: items })
} else if (items.length) {
blocks.push({ label, text: null, bullets: items })
}
} else if (body) {
blocks.push({ label, text: body, bullets: null })
}
}
return blocks
}
/** Backward-compatible alias for the interaction comparison view. */
export const parseInteractions = parseLabelSection
/** True when a text block is just a "N SECTION NAME" FDA section header. */
export function isLabelSectionHeader(text: string | null): boolean {
return !!text && /^\s*\d{1,2}\s+[A-Z][A-Z][A-Z &,/()'.-]*$/.test(text)
}
/** Backward-compatible alias. */
export const isSectionHeader = isLabelSectionHeader

558
admin/util/drug_labels.ts Normal file
View File

@ -0,0 +1,558 @@
/**
* Drug Reference v1 pure, unit-testable helpers.
*
* NO Lucid / AdonisJS / HTTP imports. These take plain objects and return plain
* objects so they run under @japa/runner without booting MySQL or Redis.
* Mirrors the `embed_jobs.ts` pattern.
*/
import path from 'node:path'
import type {
DrugLabelManifest,
DrugLabelPartition,
DownloadStateMarker,
DrugDownloadStatus,
DrugIngestPhaseStatus,
DrugIngestPhase,
} from '../types/drug_reference.js'
// ─── Internal structural types (no Lucid) ────────────────────────────────────
/**
* Structural shape of an openFDA label record only the fields we care about.
* Declared locally (no Lucid imports) so the mapper stays pure.
*/
export interface OpenFdaLabelRecord {
set_id?: string
id?: string
version?: string
effective_time?: string
openfda?: {
brand_name?: string[]
generic_name?: string[]
manufacturer_name?: string[]
product_ndc?: string[]
route?: string[]
product_type?: string[]
}
indications_and_usage?: string[]
dosage_and_administration?: string[]
warnings?: string[]
boxed_warning?: string[]
drug_interactions?: string[]
contraindications?: string[]
when_using?: string[]
stop_use?: string[]
}
/** Plain row object that matches the DrugLabel Lucid model's column shape. */
export interface DrugLabelRow {
set_id: string
spl_id: string | null
version: string | null
brand_name: string | null
generic_name: string | null
manufacturer: string | null
product_ndc: string | null
route: string | null
product_type: string | null
searchable_name: string | null
indications: string | null
dosage: string | null
warnings: string | null
boxed_warning: string | null
drug_interactions: string | null
contraindications: string | null
when_using: string | null
stop_use: string | null
source_updated_at: string | null // ISO date YYYY-MM-DD or null
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
* Flatten a section array: join with "\n\n", trim. An absent key or an empty
* array both return null (no empty strings).
*/
function flattenSection(arr: string[] | undefined): string | null {
if (!arr || arr.length === 0) return null
const joined = arr.join('\n\n').trim()
return joined.length > 0 ? joined : null
}
/**
* Return the first element of an array, or null if absent/empty.
*/
function firstOf(arr: string[] | undefined): string | null {
if (!arr || arr.length === 0) return null
return arr[0] ?? null
}
/**
* Join an array with ", ", or null if absent/empty.
*/
function joinOf(arr: string[] | undefined): string | null {
if (!arr || arr.length === 0) return null
const joined = arr.join(', ').trim()
return joined.length > 0 ? joined : null
}
/**
* Parse openFDA effective_time (YYYYMMDD) to ISO date YYYY-MM-DD.
* Returns null for missing, non-string, or invalid formats.
*/
function parseEffectiveTime(raw: string | undefined): string | null {
if (!raw || typeof raw !== 'string') return null
// Must be exactly 8 digits
if (!/^\d{8}$/.test(raw)) return null
const year = raw.slice(0, 4)
const month = raw.slice(4, 6)
const day = raw.slice(6, 8)
const y = parseInt(year, 10)
const m = parseInt(month, 10)
const d = parseInt(day, 10)
// Basic sanity check: year in a plausible FDA range, month 112, day 131
if (y < 1900 || y > 2100 || m < 1 || m > 12 || d < 1 || d > 31) return null
return `${year}-${month}-${day}`
}
/**
* Truncate a string to a maximum length (the drug_labels varchar column widths).
* openFDA joins multi-value fields e.g. every active ingredient into
* generic_name which can exceed a column's width; clamping here keeps the row
* insertable so one over-long value never fails (and drops) its whole 500-row
* upsert batch. Section bodies are mediumtext and are never clamped.
*/
function clamp(value: string | null, max: number): string | null {
if (value === null) return null
return value.length > max ? value.slice(0, max) : value
}
/** drug_labels varchar column widths — must match the migration. */
const COL = {
SET_ID: 64,
SPL_ID: 64,
VERSION: 16,
BRAND: 255,
GENERIC: 512,
MANUFACTURER: 512,
PRODUCT_NDC: 255,
ROUTE: 255,
PRODUCT_TYPE: 32,
SEARCHABLE: 768,
} as const
// ─── Exported helpers ─────────────────────────────────────────────────────────
/**
* Map a raw openFDA label record to a flat DrugLabelRow for upsert.
*
* Returns null when the record lacks a usable `set_id` (the idempotency key)
* the ingest pipeline skips those and increments `recordsSkipped`.
*
* Varchar-bound fields are length-clamped to their column widths so an over-long
* value can't fail the batch upsert. All section fields are optional (they are
* absent keys in many records, not empty arrays verified on live OTC records).
*/
export function mapDrugLabelRecord(record: OpenFdaLabelRecord): DrugLabelRow | null {
if (!record.set_id || record.set_id.trim() === '') return null
const setId = record.set_id.trim()
// set_id is the UNIQUE idempotency key — never truncate it (a truncated key
// could collide with a different label). openFDA set_ids are 36-char GUIDs, so
// this guard is defensive, not an expected path.
if (setId.length > COL.SET_ID) return null
const brand = firstOf(record.openfda?.brand_name)
const generic = joinOf(record.openfda?.generic_name)
return {
set_id: setId,
spl_id: clamp(record.id ?? null, COL.SPL_ID),
version: clamp(record.version ?? null, COL.VERSION),
brand_name: clamp(brand, COL.BRAND),
generic_name: clamp(generic, COL.GENERIC),
manufacturer: clamp(firstOf(record.openfda?.manufacturer_name), COL.MANUFACTURER),
product_ndc: clamp(joinOf(record.openfda?.product_ndc), COL.PRODUCT_NDC),
route: clamp(joinOf(record.openfda?.route), COL.ROUTE),
product_type: clamp(firstOf(record.openfda?.product_type), COL.PRODUCT_TYPE),
searchable_name: clamp(normalizeDrugName(brand, generic), COL.SEARCHABLE),
indications: flattenSection(record.indications_and_usage),
dosage: flattenSection(record.dosage_and_administration),
warnings: flattenSection(record.warnings),
boxed_warning: flattenSection(record.boxed_warning),
drug_interactions: flattenSection(record.drug_interactions),
contraindications: flattenSection(record.contraindications),
when_using: flattenSection(record.when_using),
stop_use: flattenSection(record.stop_use),
source_updated_at: parseEffectiveTime(record.effective_time),
}
}
/**
* Normalize brand + generic names into a searchable blob.
*
* Combines the two strings, lowercases, strips non-alphanumeric characters
* to spaces, collapses whitespace runs, deduplicates tokens (preserving order),
* and trims. Both null/empty null.
*
* Example: ("Tylenol Extra Strength", "acetaminophen") "tylenol extra strength acetaminophen"
* Example: ("Silicea", "SILICEA") "silicea" (deduped)
*/
export function normalizeDrugName(
brand: string | null,
generic: string | null
): string | null {
const parts: string[] = []
if (brand && brand.trim().length > 0) parts.push(brand.trim())
if (generic && generic.trim().length > 0) parts.push(generic.trim())
if (parts.length === 0) return null
const combined = parts.join(' ')
// Lowercase, replace non-alphanumeric with space
const normalized = combined.toLowerCase().replace(/[^a-z0-9]+/g, ' ')
// Split into tokens, deduplicate preserving first occurrence order
const rawTokens = normalized.split(/\s+/).filter((t) => t.length > 0)
const seen = new Set<string>()
const deduped: string[] = []
for (const token of rawTokens) {
if (!seen.has(token)) {
seen.add(token)
deduped.push(token)
}
}
const result = deduped.join(' ').trim()
return result.length > 0 ? result : null
}
/**
* Parse the download.json manifest into a typed DrugLabelManifest.
*
* Throws a descriptive error if:
* - `results.drug.label` is missing (manifest shape changed upstream)
* - The `partitions` array is absent or empty
*
* Partitions missing a `file` field are skipped with a warning logged to
* stderr so the caller can decide whether to abort.
*
* @param json - The parsed JSON object from GET https://api.fda.gov/download.json
*/
export function parseDrugLabelManifest(json: unknown): DrugLabelManifest {
if (typeof json !== 'object' || json === null) {
throw new Error('Unexpected FDA manifest format: root is not an object')
}
const root = json as Record<string, unknown>
const results = root['results'] as Record<string, unknown> | undefined
if (!results || typeof results !== 'object') {
throw new Error('Unexpected FDA manifest format: missing "results"')
}
const drug = results['drug'] as Record<string, unknown> | undefined
if (!drug || typeof drug !== 'object') {
throw new Error('Unexpected FDA manifest format: missing "results.drug"')
}
const label = drug['label'] as Record<string, unknown> | undefined
if (!label || typeof label !== 'object') {
throw new Error('Unexpected FDA manifest format: missing "results.drug.label"')
}
const export_date = label['export_date']
if (typeof export_date !== 'string' || export_date.trim() === '') {
throw new Error('Unexpected FDA manifest format: missing or invalid "export_date"')
}
const total_records = label['total_records']
if (typeof total_records !== 'number') {
throw new Error('Unexpected FDA manifest format: missing or invalid "total_records"')
}
const rawPartitions = label['partitions']
if (!Array.isArray(rawPartitions) || rawPartitions.length === 0) {
throw new Error(
'Unexpected FDA manifest format: "partitions" is missing or empty'
)
}
const partitions: DrugLabelPartition[] = []
for (const p of rawPartitions as unknown[]) {
if (typeof p !== 'object' || p === null) {
process.stderr.write('[parseDrugLabelManifest] Skipping non-object partition\n')
continue
}
const part = p as Record<string, unknown>
if (typeof part['file'] !== 'string' || (part['file'] as string).trim() === '') {
process.stderr.write(
`[parseDrugLabelManifest] Skipping partition with missing "file": ${JSON.stringify(part)}\n`
)
continue
}
partitions.push({
display_name: typeof part['display_name'] === 'string' ? part['display_name'] : '',
file: (part['file'] as string).trim(),
size_mb: typeof part['size_mb'] === 'string' ? part['size_mb'] : '0',
records: typeof part['records'] === 'number' ? part['records'] : 0,
})
}
if (partitions.length === 0) {
throw new Error(
'Unexpected FDA manifest format: all partitions were invalid (missing "file" field)'
)
}
return {
export_date: export_date.trim(),
total_records,
partitions,
}
}
// ─── Two-step ingest helpers (pure — no I/O) ──────────────────────────────────
/**
* Resolve the on-disk path of a part's zip from its manifest partition.
*
* The download job stages each part to `<storageBase>/<basename-of-file-URL>`;
* both the download job (write) and the ingest job (read) must agree on this
* path. Extracting it here (pure: path.basename + path.join do no I/O) lets both
* jobs and the tests share one definition instead of inlining the join twice.
*/
export function partZipPath(storageBase: string, partition: DrugLabelPartition): string {
return path.join(storageBase, path.basename(partition.file))
}
/**
* Sum the manifest partitions' `size_mb` into a single byte total for the Active
* Downloads card's `totalBytes`. Each partition's `size_mb` is a string of MB in
* the openFDA manifest; a non-numeric / missing value contributes 0 so a single
* bad partition can't NaN the whole total. Pure (no I/O) so the producer and the
* standalone test share one definition.
*/
export function manifestBytesTotal(manifest: DrugLabelManifest): number {
return manifest.partitions.reduce((acc, p) => {
const mb = Number(p.size_mb)
return acc + (Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 0)
}, 0)
}
/**
* Is the manifest's `export_date` newer than the last-ingested one? Drives the
* drug auto-update freshness check.
*
* TODO(maintainer Q3): CONFIRM the openFDA `export_date` string format before
* trusting this. We only have it typed as `string` in the code; the format
* (e.g. `YYYY-MM-DD` vs `YYYYMMDD` vs `MM/DD/YYYY`) determines whether a plain
* lexicographic `>` sorts chronologically. This implementation is defensive: it
* FIRST tries Date.parse on both sides and compares timestamps (correct for any
* parseable date string, including ISO and US formats); only if EITHER side
* fails to parse does it fall back to a trimmed lexicographic compare (correct
* for zero-padded ISO `YYYY-MM-DD` / `YYYYMMDD`). The fallback can misfire on a
* non-ISO, non-parseable format hence the maintainer confirmation. Either way
* it never throws and treats an unset/empty `last` as "newer" (a first install
* with no baseline should update). Pure: no I/O, fully unit-testable.
*/
export function isExportDateNewer(latest: string, last: string | null | undefined): boolean {
const l = (latest ?? '').trim()
const prev = (last ?? '').trim()
if (l === '') return false // no candidate → nothing to update to
if (prev === '') return true // no baseline → treat any valid candidate as newer
if (l === prev) return false // identical → already current
const lt = Date.parse(l)
const pt = Date.parse(prev)
if (Number.isFinite(lt) && Number.isFinite(pt)) {
return lt > pt
}
// Fallback: lexicographic. Correct only for zero-padded ISO-ordered strings.
return l > prev
}
/**
* Defensively parse the `drugReference.downloadState` KV marker.
*
* Follows the kv_store defensive-parse convention: a never-set (null) value,
* malformed JSON, or a structurally-wrong object all return null so callers fall
* back to "nothing downloaded" rather than throwing. A valid marker must carry a
* non-empty parts array with a string path on each entry.
*/
export function parseDownloadState(raw: string | null): DownloadStateMarker | null {
if (!raw) return null
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return null
}
if (typeof parsed !== 'object' || parsed === null) return null
const m = parsed as Record<string, unknown>
if (typeof m.export_date !== 'string') return null
if (typeof m.totalParts !== 'number') return null
if (!Array.isArray(m.parts) || m.parts.length === 0) return null
const parts: DownloadStateMarker['parts'] = []
for (const p of m.parts as unknown[]) {
if (typeof p !== 'object' || p === null) return null
const part = p as Record<string, unknown>
if (typeof part.path !== 'string' || part.path.trim() === '') return null
parts.push({
index: typeof part.index === 'number' ? part.index : parts.length,
name: typeof part.name === 'string' ? part.name : '',
path: part.path,
bytes: typeof part.bytes === 'number' ? part.bytes : 0,
})
}
return {
export_date: m.export_date,
totalParts: m.totalParts,
// Older markers (written before totalRecords existed) parse back as 0 =
// "unknown"; callers fall back to a parts-based estimate in that case.
totalRecords: typeof m.totalRecords === 'number' ? m.totalRecords : 0,
parts,
completedAtMs: typeof m.completedAtMs === 'number' ? m.completedAtMs : 0,
}
}
/**
* Resolve the ingest "expected total" denominator the ~259k that drives the
* "X of ~N labels" counter, the records-based progress %, and the ETA.
*
* Precedence (first known value wins, where "known" means > 0):
* 1. the manifest's real `total_records` (~259k) carried in job data on the
* auto-chained run, or rebuilt from the persisted download marker;
* 2. a parts-based estimate (`totalParts * 20000`) when the manifest total is
* not yet known (e.g. an older marker, or before the manifest is resolved);
* 3. the live row count as a last resort.
*
* The bug this fixes: `total_records ?? fallback` returns 0 when total_records
* is 0, because 0 is not nullish so the fallback was never reached and the
* counter/%/ETA silently disappeared. Treating 0 as "unknown" restores them.
*/
export function resolveExpectedTotal(
manifestTotalRecords: number | undefined,
totalParts: number | undefined,
rowCount: number,
perPartEstimate = 20000
): number {
if (manifestTotalRecords && manifestTotalRecords > 0) return manifestTotalRecords
if (totalParts && totalParts > 0) return totalParts * perPartEstimate
return rowCount
}
/**
* Resolve the ingested-record count the UI shows during the 'ingesting' phase.
*
* The bug this fixes: the per-job `recordsIngested` counter lags during the
* per-part continuation handoff. Pass 0 runs under the deterministic jobId and
* carries its count in job data; continuations (parts 2+) run under auto-ids and
* each only know the running total written into *their* job data mid-batch, so
* the number the status read surfaces can stall while the DB keeps filling.
*
* The live `drug_labels` row count is ground truth, but it is only a faithful
* progress signal on a FIRST ingest an empty (or near-empty) table climbs
* 0 ~259k as rows land. On a re-ingest into an already-populated table the row
* count barely moves (upserts replace existing rows), so it would understate
* progress; there the per-job `recordsIngested` is the better source.
*
* `startRowCount` is the table's row count when THIS RUN began (pass 0 stamps
* it into job data; continuations carry it). It fixes the re-ingest lie: into a
* populated table, raw rowCount made the counter read ~100% from second zero.
* The live-table progress signal for this run is rowCount - startRowCount (new
* rows only); jobRecords covers the upsert-update case where the table count
* barely moves. Taking max of the two:
* - first ingest (start 0): identical to the old max(jobRecords, rowCount);
* - re-ingest: rowCount - start stays near 0, jobRecords climbs 0 ~259k and
* drives the counter honestly.
*
* Clamped to `expectedTotal` (when known) so the count can't briefly read past
* the denominator and push the bar over 100%.
*/
export function resolveIngestRecordsShown(
jobRecords: number,
rowCount: number,
expectedTotal: number,
startRowCount = 0
): number {
const newRows = Math.max(0, rowCount - Math.max(0, startRowCount))
const best = Math.max(jobRecords, newRows)
if (expectedTotal > 0) return Math.min(best, expectedTotal)
return best
}
/**
* Reduce a raw job failure message to something a human can read in the UI.
*
* A knex/mysql2 error message embeds the ENTIRE failed SQL statement for a
* 500-row drug-label insert that is thousands of characters of SQL plus full
* label text, which the status panel then rendered verbatim (a wall of red).
* The meaningful part is the driver error the database appended at the END
* ("Duplicate entry … for key …", "Data too long for column …", etc.).
*
* Strategy: match the known MySQL driver-error shapes anywhere in the message
* and return just that sentence; otherwise fall back to the first line,
* hard-capped. Pure + unit-testable.
*/
export function summarizeJobError(raw: string | null | undefined, maxLen = 300): string | undefined {
if (!raw) return undefined
const msg = String(raw).trim()
if (!msg) return undefined
const driverPatterns: RegExp[] = [
/Duplicate entry .{0,120}? for key '[^']+'/,
/Data too long for column '[^']+'[^\n]{0,80}/,
/Incorrect \w+ value: .{0,120}/,
/Unknown column '[^']+' in '[^']+'/,
/Deadlock found[^\n]{0,120}/,
/Lock wait timeout[^\n]{0,120}/,
/ER_[A-Z_]+[^\n]{0,160}/,
/connect ECONNREFUSED [^\s]+/,
/timed out after \d+ms[^\n]{0,160}/,
]
for (const pat of driverPatterns) {
const m = msg.match(pat)
if (m) return m[0].slice(0, maxLen)
}
// No recognizable driver error — take the first line, capped. A leading
// "insert into `drug_labels` (…)" dump is all one line, so the cap is what
// actually saves the UI here.
const firstLine = msg.split('\n')[0]
return firstLine.length > maxLen ? `${firstLine.slice(0, maxLen)}` : firstLine
}
/**
* Derive the top-level ingest phase from the two sub-phase states + the live row
* count. This is the state machine the UI keys off of:
*
* - ingest running 'ingesting'
* - download running 'downloading'
* - either phase failed (ingest wins) 'failed'
* - ingest completed (or rows already exist
* with no active work) 'ready'
* - download completed, ingest not done 'downloaded'
* - nothing happening, no rows 'idle'
*
* Ordering matters: an in-flight phase is reported before a terminal one so a
* re-ingest after a prior success shows 'ingesting', not 'ready'. A failed
* download does NOT force the ingest phase to fail (and vice-versa) only the
* phase that actually failed surfaces, and a running phase always outranks a
* sibling failure.
*/
export function deriveIngestPhase(
download: DrugDownloadStatus,
ingest: DrugIngestPhaseStatus,
rowCount: number
): DrugIngestPhase {
if (ingest.state === 'running') return 'ingesting'
if (download.state === 'running') return 'downloading'
// No phase is actively running below this point.
if (ingest.state === 'failed') return 'failed'
if (download.state === 'failed') return 'failed'
if (ingest.state === 'completed') return 'ready'
if (download.state === 'completed') return 'downloaded'
// Idle: surface 'ready' if there is already searchable data from a past run.
if (rowCount > 0) return 'ready'
return 'idle'
}

426
collections/conditions.json Normal file
View File

@ -0,0 +1,426 @@
{
"version": "2026-06-07",
"conditions": [
{
"slug": "pain",
"label": "Pain",
"category": "Pain, fever & inflammation",
"searchTerms": [
"pain",
"aches",
"minor aches",
"pain relief",
"analgesic"
]
},
{
"slug": "headache",
"label": "Headache",
"category": "Pain, fever & inflammation",
"searchTerms": [
"headache",
"migraine",
"head pain",
"tension headache"
]
},
{
"slug": "muscle-joint-pain",
"label": "Muscle & joint pain",
"category": "Pain, fever & inflammation",
"searchTerms": [
"muscular aches",
"muscle aches",
"backache",
"arthritis",
"joint pain",
"minor pain of arthritis"
]
},
{
"slug": "fever",
"label": "Fever",
"category": "Pain, fever & inflammation",
"searchTerms": [
"fever",
"reduces fever",
"fever reducer",
"temporarily reduces fever"
]
},
{
"slug": "menstrual-cramps",
"label": "Menstrual cramps",
"category": "Pain, fever & inflammation",
"searchTerms": [
"menstrual cramps",
"menstrual pain",
"period pain",
"premenstrual"
]
},
{
"slug": "cough",
"label": "Cough",
"category": "Cold, cough & allergy",
"searchTerms": [
"cough",
"cough suppressant",
"coughing",
"controls cough"
]
},
{
"slug": "nasal-congestion",
"label": "Nasal congestion",
"category": "Cold, cough & allergy",
"searchTerms": [
"nasal congestion",
"stuffy nose",
"sinus congestion",
"decongestant",
"nasal decongestant"
]
},
{
"slug": "sore-throat",
"label": "Sore throat",
"category": "Cold, cough & allergy",
"searchTerms": [
"sore throat",
"sore mouth",
"minor sore throat",
"throat pain"
]
},
{
"slug": "common-cold",
"label": "Common cold",
"category": "Cold, cough & allergy",
"searchTerms": [
"common cold",
"cold symptoms",
"cold",
"flu symptoms"
]
},
{
"slug": "allergic-reaction",
"label": "Allergies & allergic reactions",
"category": "Cold, cough & allergy",
"searchTerms": [
"allergy",
"allergic reactions",
"hay fever",
"antihistamine",
"runny nose",
"sneezing",
"itchy watery eyes"
]
},
{
"slug": "heartburn",
"label": "Heartburn",
"category": "Stomach & digestion",
"searchTerms": [
"heartburn",
"acid indigestion",
"acid reducer",
"antacid",
"sour stomach"
]
},
{
"slug": "indigestion",
"label": "Indigestion & upset stomach",
"category": "Stomach & digestion",
"searchTerms": [
"indigestion",
"upset stomach",
"gas",
"bloating",
"fullness",
"antacid"
]
},
{
"slug": "nausea-vomiting",
"label": "Nausea & vomiting",
"category": "Stomach & digestion",
"searchTerms": [
"nausea",
"vomiting",
"upset stomach associated with nausea"
]
},
{
"slug": "diarrhea",
"label": "Diarrhea",
"category": "Stomach & digestion",
"searchTerms": [
"diarrhea",
"antidiarrheal",
"loose stools",
"travelers diarrhea"
]
},
{
"slug": "constipation",
"label": "Constipation",
"category": "Stomach & digestion",
"searchTerms": [
"constipation",
"laxative",
"irregularity",
"occasional constipation",
"stool softener"
]
},
{
"slug": "motion-sickness",
"label": "Motion sickness",
"category": "Stomach & digestion",
"searchTerms": [
"motion sickness",
"travel sickness",
"seasickness",
"car sickness"
]
},
{
"slug": "gas",
"label": "Gas & bloating",
"category": "Stomach & digestion",
"searchTerms": [
"gas",
"bloating",
"flatulence",
"antigas",
"pressure"
]
},
{
"slug": "wounds-cuts",
"label": "Wounds & cuts",
"category": "Skin & wounds",
"searchTerms": [
"minor cuts",
"scrapes",
"wounds",
"first aid antiseptic",
"first aid to help prevent infection",
"abrasions"
]
},
{
"slug": "burns",
"label": "Burns",
"category": "Skin & wounds",
"searchTerms": [
"burns",
"minor burns",
"sunburn",
"scald",
"minor burn"
]
},
{
"slug": "insect-bites-stings",
"label": "Insect bites & stings",
"category": "Skin & wounds",
"searchTerms": [
"insect bites",
"insect stings",
"bug bites",
"bee sting",
"itching from insect bites"
]
},
{
"slug": "skin-rash-itch",
"label": "Rash & itching",
"category": "Skin & wounds",
"searchTerms": [
"itching",
"rash",
"skin irritation",
"itchy skin",
"minor skin irritations",
"eczema"
]
},
{
"slug": "poison-ivy",
"label": "Poison ivy & plant rashes",
"category": "Skin & wounds",
"searchTerms": [
"poison ivy",
"poison oak",
"poison sumac",
"rashes due to poison ivy"
]
},
{
"slug": "fungal-infection",
"label": "Athletes foot & ringworm",
"category": "Skin & wounds",
"searchTerms": [
"athletes foot",
"ringworm",
"jock itch",
"antifungal",
"fungal infection",
"tinea"
]
},
{
"slug": "dry-skin",
"label": "Dry & chapped skin",
"category": "Skin & wounds",
"searchTerms": [
"dry skin",
"chapped skin",
"chapped lips",
"skin protectant",
"cracked skin"
]
},
{
"slug": "acne",
"label": "Acne",
"category": "Skin & wounds",
"searchTerms": [
"acne",
"pimples",
"blackheads",
"acne treatment"
]
},
{
"slug": "eye-irritation",
"label": "Eye irritation & dryness",
"category": "Eyes, ears & mouth",
"searchTerms": [
"eye irritation",
"dry eyes",
"red eyes",
"eye redness",
"itchy eyes",
"lubricant eye"
]
},
{
"slug": "earache",
"label": "Earache & ear wax",
"category": "Eyes, ears & mouth",
"searchTerms": [
"earache",
"ear wax",
"earwax removal",
"ear pain",
"swimmers ear"
]
},
{
"slug": "canker-sores",
"label": "Canker & cold sores",
"category": "Eyes, ears & mouth",
"searchTerms": [
"canker sores",
"cold sores",
"mouth sores",
"fever blisters",
"oral pain"
]
},
{
"slug": "toothache",
"label": "Toothache",
"category": "Eyes, ears & mouth",
"searchTerms": [
"toothache",
"tooth pain",
"dental pain",
"oral analgesic"
]
},
{
"slug": "sleeplessness",
"label": "Sleeplessness",
"category": "Sleep, stress & general",
"searchTerms": [
"sleeplessness",
"insomnia",
"sleep aid",
"difficulty falling asleep",
"nighttime"
]
},
{
"slug": "dehydration",
"label": "Dehydration",
"category": "Sleep, stress & general",
"searchTerms": [
"dehydration",
"oral rehydration",
"electrolyte",
"fluid loss",
"replaces electrolytes"
]
},
{
"slug": "hemorrhoids",
"label": "Hemorrhoids",
"category": "Sleep, stress & general",
"searchTerms": [
"hemorrhoids",
"hemorrhoidal",
"anal itching",
"rectal"
]
},
{
"slug": "yeast-infection",
"label": "Vaginal yeast infection",
"category": "Infections (OTC-treatable)",
"searchTerms": [
"vaginal yeast infection",
"yeast infection",
"vaginal antifungal",
"candidiasis"
]
},
{
"slug": "pinworm",
"label": "Pinworm",
"category": "Infections (OTC-treatable)",
"searchTerms": [
"pinworm",
"pinworm infection",
"pinworm treatment"
]
},
{
"slug": "cold-sore-lip",
"label": "Chapped & sun-protected lips",
"category": "Sleep, stress & general",
"searchTerms": [
"lip protectant",
"chapped lips",
"sunburn protection lips",
"lip balm"
]
},
{
"slug": "eye-allergy",
"label": "Eye allergies",
"category": "Eyes, ears & mouth",
"searchTerms": [
"eye allergy",
"itchy eyes due to allergies",
"ocular itching",
"allergic conjunctivitis"
]
}
]
}

View File

@ -0,0 +1,230 @@
{
"version": "2026-06-10",
"source": {
"name": "US government health guidance (CDC, NIH/NHLBI, MedlinePlus/NLM, FDA)",
"url": "https://www.cdc.gov",
"license": "Public domain (US government works)"
},
"remedies": [
{
"slug": "honey-for-cough",
"name": "Honey (for cough)",
"commonNames": [],
"conditions": ["cough", "sore-throat", "common-cold"],
"uses": "Honey may be used to relieve cough in adults and children at least 1 year old. One to two teaspoons can be taken directly or stirred into a warm (not hot) beverage.",
"how": "Give one to two teaspoons of honey directly by mouth, or stir it into a warm (not hot) drink. The CDC recommends this as a home measure for cough associated with the common cold.",
"evidence": "CDC lists honey among recommended home measures for easing cough and sore throat associated with the common cold; it is one of several non-medication strategies mentioned alongside rest, fluids, and humidifier use.",
"cautions": "Never give honey to infants under 1 year old — it can contain Clostridium botulinum spores that cause infant botulism, a rare but serious illness. This warning applies regardless of honey type or brand.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "fluids-and-rest",
"name": "Fluids and rest",
"commonNames": [],
"conditions": ["common-cold", "fever", "diarrhea", "nausea-vomiting"],
"uses": "Getting plenty of rest and drinking adequate fluids (water, clear broths, juice, or sports drinks) supports recovery from colds, fever, diarrhea, and nausea. Adults with diarrhea should drink water, fruit juices, sports drinks, sodas without caffeine, and salty broths.",
"how": "Get plenty of rest and drink plenty of fluids. If keeping liquids down is difficult, take small sips of water or suck on ice chips frequently rather than trying to drink large amounts at once.",
"evidence": "CDC recommends rest and fluids as primary home care measures for the common cold. MedlinePlus (NIH/NIDDK) similarly lists these as the foundation of diarrhea self-care, and advises nausea patients to take in small amounts of clear liquids often to stay hydrated.",
"cautions": "Severely ill individuals, those with signs of dehydration (no urination, sunken eyes, extreme thirst), or those unable to keep any fluid down should seek medical care promptly. Caffeine and alcohol are not effective rehydration choices.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "oral-rehydration",
"name": "Oral rehydration solution (ORS)",
"commonNames": ["ORS", "rehydration salts"],
"conditions": ["dehydration", "diarrhea"],
"uses": "Oral rehydration solutions replace fluids and electrolytes lost through diarrhea or other causes of dehydration. For mild to moderate dehydration, drinking water is the first step; sports drinks or oral rehydration solutions (such as Pedialyte) are recommended when electrolytes are also depleted, especially for children.",
"how": "Use a commercially prepared oral rehydration solution (available without a prescription) and follow the package directions. For adults with electrolyte losses, sports drinks can help; if liquids are hard to keep down, take small sips frequently or suck on ice chips rather than drinking large amounts at once.",
"evidence": "MedlinePlus (NIH/NIDDK) states that treatment for dehydration involves replacing lost fluids and electrolytes, and that oral rehydration solutions for children are available without a prescription. The same source recommends sports drinks for adults when electrolytes have been lost alongside fluids.",
"cautions": "Seek immediate medical care for signs of severe dehydration: no urination for 8 or more hours, rapid heartbeat, confusion, or inability to keep fluids down. Infants and small children with diarrhea should use formulated ORS (not plain water) to replace electrolytes safely.",
"sourceUrl": "https://medlineplus.gov/dehydration.html"
},
{
"slug": "cool-compress-fever",
"name": "Cool compress (for fever and insect bites)",
"commonNames": [],
"conditions": ["fever", "insect-bites-stings", "eye-allergy", "eye-irritation"],
"uses": "A clean cloth soaked in cool (not ice-cold) water and placed on the forehead or bitten area can help reduce discomfort from fever, insect bites, and eye allergy symptoms. For insect stings, ice wrapped in a washcloth should be applied for 10 minutes on and 10 minutes off.",
"how": "Soak a clean cloth in cool water, wring it out, and place it on the forehead or affected area. For insect stings, wrap ice in a cloth and apply for 10 minutes on, then 10 minutes off — never place ice directly on bare skin.",
"evidence": "MedlinePlus (NIH/NIAID) notes that applying cool compresses is recommended for allergic conjunctivitis and eye burning and irritation. For insect bites and stings, a cool or iced compress is a standard first-line self-care step described in MedlinePlus search guidance consistent with NIH resources.",
"cautions": "Do not apply ice or an ice-cold compress directly to bare skin for extended periods — wrap ice in cloth and limit applications to 1015 minutes to avoid frostbite or tissue damage. For fever, cool compresses supplement (but do not replace) appropriate fever-reducing medicine when indicated; consult a healthcare provider if fever is high, prolonged, or accompanied by severe symptoms.",
"sourceUrl": "https://medlineplus.gov/insectbitesandstings.html"
},
{
"slug": "ice-and-elevation",
"name": "Ice and elevation (RICE method)",
"commonNames": ["RICE", "Rest-Ice-Compression-Elevation"],
"conditions": ["muscle-joint-pain", "insect-bites-stings"],
"uses": "Applying ice wrapped in cloth to a strained muscle, sprain, or bite site — combined with rest, compression, and elevation of the injured area — reduces swelling and pain. Ice should be applied for 1015 minutes every 13 hours during the first few days of injury.",
"how": "Wrap ice in a cloth or towel and apply to the injured area for 1015 minutes at a time. Rest the area, wrap it snugly with a bandage to reduce swelling, and elevate it above heart level when possible.",
"evidence": "MedlinePlus (NIH/NIAMS) describes the RICE method as standard first-line treatment for sprains and strains: resting the area, icing it, compressing it with a bandage, and elevating it above heart level when possible. Ice use for the first 3 days is specifically mentioned.",
"cautions": "Never apply ice directly to skin — always wrap it in a cloth or towel. If swelling worsens significantly, numbness develops, or you suspect a fracture, seek medical evaluation. Do not use heat during the first 4872 hours of an acute soft-tissue injury.",
"sourceUrl": "https://medlineplus.gov/sprainsandstrains.html"
},
{
"slug": "heating-pad",
"name": "Heating pad or warm compress",
"commonNames": [],
"conditions": ["menstrual-cramps", "muscle-joint-pain", "earache"],
"uses": "Applying a heating pad or hot water bottle to the lower abdomen eases menstrual cramps. A warm compress applied to the ear can relieve earache discomfort. Heat may also be used on strained muscles after the first 4872 hours of an acute injury.",
"how": "Place a heating pad or hot water bottle on the lower abdomen for menstrual cramps, or hold a warm cloth against the affected ear for earache. Use a low or medium heat setting and put a cloth between the pad and skin to prevent burns.",
"evidence": "MedlinePlus (NIH/NLM) lists using a heating pad or hot water bottle on the lower abdomen, along with taking a warm bath, as home care measures for period pain. For earache, placing a warm cloth on the affected ear is listed among comfort measures in NIH/NLM resources for acute ear infection self-care.",
"cautions": "Never fall asleep with a heating pad on — burns can result. Use a low or medium setting and place a cloth between the pad and skin. For ear pain, do not insert anything into the ear canal; if pain is severe, accompanied by drainage, hearing loss, or fever, see a healthcare provider to rule out infection requiring antibiotics.",
"sourceUrl": "https://medlineplus.gov/periodpain.html"
},
{
"slug": "humidifier-and-steam",
"name": "Humidifier or steam inhalation",
"commonNames": ["cool-mist vaporizer", "steam inhalation"],
"conditions": ["nasal-congestion", "cough", "common-cold"],
"uses": "Using a clean humidifier or cool-mist vaporizer adds moisture to the air and can help relieve nasal congestion and cough. Breathing steam from a bowl of hot water or a running shower 24 times daily also loosens nasal secretions.",
"how": "Fill a clean cool-mist humidifier or vaporizer with water and run it in the room. Clean the device daily per the manufacturer's instructions to prevent mold and bacteria buildup.",
"evidence": "CDC recommends using a clean humidifier or cool-mist vaporizer as a home care measure for common cold symptoms including congestion and cough. MedlinePlus similarly notes that a humidifier can break up mucus and that steam inhalation from a shower is a recognized congestion-relief strategy.",
"cautions": "Clean the humidifier daily per manufacturer instructions to prevent mold and bacterial growth. Use cool-mist humidifiers rather than warm-mist (steam) versions for children to avoid burn risk. When inhaling steam from hot water, use caution to avoid scalding; keep a safe distance and do not cover your head over a pot of boiling water.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "saline-nasal-rinse",
"name": "Saline nasal rinse",
"commonNames": ["neti pot", "nasal irrigation", "saline nasal wash"],
"conditions": ["nasal-congestion", "common-cold"],
"uses": "Saline nasal rinses flush pollen, dust, and excess mucus from the nasal passages and add moisture. They can be performed with a neti pot, squeeze bottle, or bulb syringe using a prepared saline solution.",
"how": "Use only distilled, sterile, or previously boiled-and-cooled water — never tap water. After each use, rinse the device with the same safe water, then air-dry it thoroughly or wipe dry before storing.",
"evidence": "CDC recommends saline nasal spray or drops as a home care measure for the common cold. The FDA confirms that nasal irrigation devices are 'usually safe and effective products when used and cleaned properly,' with the critical safety requirement being the type of water used.",
"cautions": "Use only distilled, sterile, or previously boiled (and cooled) water — never tap water. The FDA warns that tap water can harbor organisms including amoebas that are safe to swallow but can cause serious or potentially fatal infections in the nasal passages. Boiled water should be cooled to lukewarm and stored in a clean, closed container for no more than 24 hours. Always clean and dry the device after each use.",
"sourceUrl": "https://www.fda.gov/consumers/consumer-updates/rinsing-your-sinuses-neti-pots-safe"
},
{
"slug": "oatmeal-bath",
"name": "Oatmeal or cool bath",
"commonNames": ["colloidal oatmeal bath"],
"conditions": ["skin-rash-itch", "poison-ivy", "dry-skin"],
"uses": "Soaking in a lukewarm oatmeal bath or taking a cool bath can relieve itching and skin irritation from rashes, poison ivy, eczema, and dry skin. Colloidal oatmeal bath products are available at drugstores.",
"how": "Fill a tub with lukewarm (not hot) water and add a colloidal oatmeal bath product according to package directions, or use plain cool water. After soaking, pat skin dry gently and apply a fragrance-free moisturizer immediately to lock in moisture.",
"evidence": "MedlinePlus (NLM) recommends taking 'lukewarm or oatmeal baths' as a self-care measure for itching, alongside cool compresses and moisturizing lotion. Oatmeal bath products are specifically noted to relieve symptoms of eczema and psoriasis; short, cooler baths are described as better than long, hot baths for skin conditions.",
"cautions": "Use lukewarm — not hot — water; hot water can worsen skin dryness and irritation. After bathing, pat skin dry gently (do not rub) and immediately apply a fragrance-free moisturizer to lock in moisture. If a rash is spreading rapidly, is accompanied by fever, or involves the face or genitals, consult a healthcare provider.",
"sourceUrl": "https://medlineplus.gov/itching.html"
},
{
"slug": "cool-running-water-on-burns",
"name": "Cool running water on burns",
"commonNames": [],
"conditions": ["burns"],
"uses": "For minor burns, immediately run cool (not cold) water slowly over the burned area for 1015 minutes to stop the burning process and reduce pain. After cooling, cover the burn with a clean, dry cloth or sterile bandage.",
"how": "Run cool water slowly over the burned area for several minutes, then cover with a clean, dry cloth or bandage. Do not apply ice, butter, or any creams — these can worsen tissue damage.",
"evidence": "CDC burn first-aid materials instruct: run cool water slowly over the burn area for several minutes, then cover with a clean, dry cloth or bandage. MedlinePlus (NIH/NIGMS) similarly specifies cool running water for 1015 minutes followed by a dry sterile dressing as the appropriate first-aid response for minor burns.",
"cautions": "Do not apply ice, ice water, butter, first-aid creams, sprays, or home remedies — these can worsen tissue damage or introduce infection. Do not break blisters unless directed by a healthcare provider. Do not try to remove clothing or debris stuck to the burn. Seek immediate medical care for large burns, burns on the face, eyes, hands, or feet, burns from chemicals or electricity, or any burn with extreme pain, numbness, or deep tissue involvement.",
"sourceUrl": "https://medlineplus.gov/burns.html"
},
{
"slug": "clean-and-cover-wounds",
"name": "Clean and cover wounds and cuts",
"commonNames": [],
"conditions": ["wounds-cuts"],
"uses": "For minor cuts and scrapes, rinse the wound thoroughly with cool clean water to remove dirt, apply gentle pressure with gauze to stop bleeding, then cover with a clean bandage. Wash with soap and water to reduce infection risk.",
"how": "Apply firm but gentle pressure with gauze to stop bleeding; if blood soaks through, add more gauze on top without removing the first layer. Rinse the wound with cool clean water, then cover it with a clean dry bandage.",
"evidence": "CDC guidance on wound care states: put pressure on a bleeding cut until it stops, gently pour clean water over the wound to clean it, then apply a clean, dry bandage. MedlinePlus (NLM) similarly advises rinsing cuts with cool water and applying firm but gentle pressure to stop bleeding.",
"cautions": "Watch for signs of infection in the days following — increasing redness, swelling, warmth, pus, or red streaks spreading from the wound require prompt medical evaluation. Seek immediate care for wounds that are deep, gaping, caused by an animal or human bite, or associated with a puncture from a potentially contaminated object (tetanus risk).",
"sourceUrl": "https://medlineplus.gov/firstaid.html"
},
{
"slug": "salt-water-gargle",
"name": "Salt-water gargle",
"commonNames": [],
"conditions": ["sore-throat", "common-cold", "canker-sores"],
"uses": "Gargling with warm salt water several times a day can ease sore throat pain and may also help relieve canker sore discomfort. A standard preparation is ½ teaspoon (3 grams) of salt dissolved in 1 cup (240 mL) of warm water.",
"how": "Dissolve salt in a cup of warm water, take a mouthful, tilt your head back, and gargle for several seconds before spitting it out. Repeat as needed throughout the day to help ease sore throat pain.",
"evidence": "MedlinePlus (NLM) states that gargling may ease sore throat pain, listing it alongside lozenges and fluids. The same resource notes that salt-water rinses may help with canker sore discomfort, though mouthwashes containing alcohol should be avoided as they irritate the tissue.",
"cautions": "Gargling with salt water provides symptomatic relief only and does not treat the underlying cause of a sore throat. Sore throat accompanied by high fever, difficulty swallowing or breathing, drooling, a stiff neck, or lasting more than a week should be evaluated by a healthcare provider, as strep throat and other conditions require different treatment.",
"sourceUrl": "https://medlineplus.gov/sorethroat.html"
},
{
"slug": "fiber-and-water-constipation",
"name": "Dietary fiber and water (for constipation and hemorrhoids)",
"commonNames": [],
"conditions": ["constipation", "hemorrhoids"],
"uses": "Eating more fruits, vegetables, and whole grains (which are high in fiber) and drinking plenty of water each day are the foundational self-care steps for preventing and relieving constipation and reducing hemorrhoid discomfort.",
"how": "Eat more fruits, vegetables, and whole grains each day and drink plenty of fluids. Increase fiber gradually to avoid gas and bloating.",
"evidence": "MedlinePlus (NIH/NIDDK) lists increased dietary fiber and adequate fluid intake as primary self-care measures for both constipation and hemorrhoids. The hemorrhoids topic page specifically recommends eating high-fiber foods and drinking enough fluids every day as the first-line home treatment.",
"cautions": "Increase dietary fiber gradually to avoid gas and bloating. If constipation is new, severe, accompanied by blood in the stool, or associated with significant weight loss, see a healthcare provider to rule out underlying conditions. Hemorrhoid symptoms persisting beyond one week of home treatment, or any rectal bleeding, warrant medical evaluation.",
"sourceUrl": "https://medlineplus.gov/constipation.html"
},
{
"slug": "sitz-bath",
"name": "Sitz bath (for hemorrhoids)",
"commonNames": [],
"conditions": ["hemorrhoids"],
"uses": "A sitz bath — sitting in a few inches of warm water for 1015 minutes, several times a day — relieves the pain and itching of hemorrhoids. A special sitz bath tub that fits over a toilet is available at pharmacies.",
"how": "Fill a tub or sitz bath basin with a few inches of comfortably warm water and sit in it for 10 to 15 minutes. Repeat several times a day, keeping the area clean and dry between baths.",
"evidence": "MedlinePlus (NLM) lists taking warm baths several times a day, including sitz baths, as a recommended home care measure to relieve hemorrhoid pain. The recommendation is to sit in warm water for 10 to 15 minutes per session.",
"cautions": "The water should be comfortably warm — not hot — to avoid burns. Keep the area clean and dry between baths. If hemorrhoid symptoms do not improve after one week of home treatment, or if there is rectal bleeding, see a healthcare provider.",
"sourceUrl": "https://medlineplus.gov/hemorrhoids.html"
},
{
"slug": "elevate-head-heartburn",
"name": "Elevate head of bed (for heartburn)",
"commonNames": [],
"conditions": ["heartburn", "indigestion"],
"uses": "Raising the head of the bed 46 inches (using blocks under the bed frame or a wedge support) prevents stomach acid from backing up into the esophagus during sleep, reducing nighttime heartburn and GERD symptoms.",
"how": "Place blocks under the legs at the head of the bed frame, or use a foam wedge under the mattress, to raise the sleeping surface about 6 inches. Using extra pillows under only the head is less effective because it bends the body at the waist rather than tilting the whole torso.",
"evidence": "MedlinePlus heartburn resources (sourced from NIH/NIDDK) consistently recommend elevating the head during sleep as a lifestyle measure for heartburn and GERD, noting that this position helps prevent reflux. Sleeping with the head raised about 6 inches is a specific recommendation described in the Medical Encyclopedia entry.",
"cautions": "Using extra pillows under the head is less effective than raising the entire upper body — pillows can cause neck strain and do not sufficiently change the angle. Heartburn that is frequent, severe, or accompanied by difficulty swallowing, unexplained weight loss, or vomiting blood requires medical evaluation.",
"sourceUrl": "https://medlineplus.gov/heartburn.html"
},
{
"slug": "bland-small-meals",
"name": "Small, frequent bland meals",
"commonNames": ["BRAT diet", "bland diet"],
"conditions": ["nausea-vomiting", "indigestion", "diarrhea"],
"uses": "Eating 68 small bland meals throughout the day (crackers, toast, baked chicken, rice, potatoes) instead of 3 large meals reduces nausea and eases indigestion. As diarrhea symptoms improve, soft bland foods can be introduced gradually.",
"how": "Eat smaller meals more often and stick to bland foods, avoiding spicy, fatty, or salty options. If you have trouble keeping food down, start with small sips of clear liquids frequently and add bland solids only when tolerated.",
"evidence": "MedlinePlus (NLM) recommends small, frequent bland meals and avoiding spicy, fatty, or salty foods as the primary dietary self-care for nausea and vomiting. For indigestion, MedlinePlus notes that avoiding foods and situations that trigger symptoms is the main home strategy. For diarrhea, 'soft, bland food' is recommended as symptoms improve.",
"cautions": "The BRAT diet (bananas, rice, applesauce, toast) was historically promoted but MedlinePlus notes there is not strong evidence it is better than a standard bland diet; it probably does not cause harm. If nausea or vomiting persists beyond 2448 hours, is accompanied by severe pain, or prevents adequate fluid intake, seek medical care.",
"sourceUrl": "https://medlineplus.gov/nauseaandvomiting.html"
},
{
"slug": "dark-quiet-room-headache",
"name": "Rest in a dark, quiet room (for headache)",
"commonNames": [],
"conditions": ["headache", "sleeplessness"],
"uses": "Resting with eyes closed in a dark, quiet room is a recommended non-medication self-care measure during a headache or migraine. Drinking water to prevent dehydration and placing a cool cloth on the forehead are often combined with this rest.",
"how": "Go to a quiet, darkened room, close your eyes, and rest. Drink water and place a cool damp cloth on your forehead to help ease discomfort.",
"evidence": "MedlinePlus (NLM) describes resting in a quiet, darkened room as one of the key things you can do to feel better during a headache or migraine, alongside drinking water and using relaxation techniques. These recommendations are attributed to NIH sources on headache and migraine management.",
"cautions": "Sudden severe ('thunderclap') headache, headache with fever, stiff neck, confusion, or vision changes may signal a serious condition and require emergency evaluation. Frequent headaches that disrupt daily life should be discussed with a healthcare provider rather than managed solely at home.",
"sourceUrl": "https://medlineplus.gov/headache.html"
},
{
"slug": "sleep-hygiene",
"name": "Sleep hygiene practices",
"commonNames": ["good sleep habits"],
"conditions": ["sleeplessness"],
"uses": "A consistent set of behavioral practices — consistent bedtime and wake time, a cool and dark bedroom, avoiding screens and caffeine near bedtime, and regular exercise — helps adults achieve and maintain adequate sleep.",
"how": "Go to bed and wake up at the same time every day. Keep the bedroom quiet, dark, and cool; turn off electronic devices at least 30 minutes before bedtime; and avoid caffeine in the afternoon and evening and large meals or alcohol before bed.",
"evidence": "CDC and NHLBI both recommend these specific practices for healthy sleep: going to bed and waking at the same time daily, keeping the bedroom quiet, cool, and dark, turning off screens at least 30 minutes before bed, avoiding caffeine in the afternoon and evening, and avoiding large meals or alcohol before sleep. NHLBI notes these habits are particularly important for shift workers and people with insomnia.",
"cautions": "Good sleep hygiene can help relieve short-term insomnia; persistent insomnia lasting more than a few weeks should be evaluated by a healthcare provider. Sleep difficulties accompanied by snoring, gasping during sleep, or excessive daytime sleepiness may indicate obstructive sleep apnea, which requires medical diagnosis.",
"sourceUrl": "https://www.cdc.gov/sleep/about/index.html"
},
{
"slug": "pinworm-hygiene",
"name": "Hygiene measures for pinworm",
"commonNames": [],
"conditions": ["pinworm"],
"uses": "Thorough handwashing with soap and warm water (especially after toilet use and before eating), daily morning bathing with soap and water, daily underwear changes, short and clean fingernails, and laundering of bedding and pajamas in hot water are the key household self-care measures for managing pinworm infection.",
"how": "Bathe after waking up each morning and wash hands regularly, especially after using the bathroom. Change underwear daily, wash pajamas and bed sheets often, and avoid nail biting to prevent reinfection.",
"evidence": "CDC identifies handwashing as 'the most important way to prevent the spread of pinworms.' MedlinePlus (NIH/NIAID) lists bathe after waking up, wash pajamas and bed sheets often, wash hands regularly, change underwear every day, and avoid nail biting and scratching the anal area as the core preventive hygiene steps.",
"cautions": "Hygiene alone typically cannot eliminate an active pinworm infection — over-the-counter antiparasitic medication (pyrantel pamoate) is generally needed, and all household members should be treated simultaneously. Medication is typically repeated after 2 weeks because it kills worms but not eggs; the second dose treats worms that hatched after the first dose.",
"sourceUrl": "https://medlineplus.gov/pinworms.html"
},
{
"slug": "keep-feet-dry-athlete-foot",
"name": "Keep feet clean and dry (for fungal infection)",
"commonNames": ["athlete's foot self-care"],
"conditions": ["fungal-infection"],
"uses": "Keeping the feet clean, dry, and cool — including washing daily with soap and water, drying carefully between the toes, wearing clean cotton socks, and not walking barefoot in public showers or locker rooms — supports treatment and prevents spread of athlete's foot and other tinea infections.",
"how": "Keep feet clean, dry, and cool; wear clean socks and avoid walking barefoot in public areas such as locker room showers (use flip-flops instead). Apply an over-the-counter antifungal cream as directed on the package for most cases of athlete's foot.",
"evidence": "MedlinePlus (CDC-sourced) advises: keep your feet clean, dry, and cool; wear clean socks; avoid walking barefoot in public areas; wear flip-flops in locker room showers; keep toenails clean and clipped short. Over-the-counter antifungal creams work for most cases of athlete's foot.",
"cautions": "If over-the-counter antifungal treatment does not improve the infection within 24 weeks, see a healthcare provider. Spreading or worsening redness, warmth, and swelling — especially in people with diabetes or circulatory problems — warrants prompt medical care. Nail fungal infections are more difficult to treat than skin infections and often require prescription therapy.",
"sourceUrl": "https://medlineplus.gov/athletesfoot.html"
}
]
}

View File

@ -61,6 +61,15 @@
"description": "NIH's consumer health encyclopedia - diseases, conditions, drugs, supplements",
"url": "https://download.kiwix.org/zim/zimit/medlineplus.gov_en_all_2025-01.zim",
"size_mb": 1800
},
{
"id": "openfda-drug-labels",
"type": "dataset",
"version": "2025-01",
"title": "FDA Drug Reference",
"description": "Offline FDA drug labels - search by drug name",
"url": "https://api.fda.gov/download.json",
"size_mb": 1700
}
]
},

View File

@ -0,0 +1,203 @@
{
"version": "2026-06-10",
"source": {
"name": "NCCIH — Herbs at a Glance",
"url": "https://www.nccih.nih.gov/health/herbsataglance",
"license": "Public domain (US government work; NCCIH)"
},
"remedies": [
{
"slug": "aloe-vera",
"name": "Aloe Vera",
"commonNames": ["Aloe barbadensis miller"],
"conditions": ["burns", "acne", "dry-skin"],
"uses": "Aloe vera gel is applied topically for burns, acne, and various skin conditions including psoriasis and radiation-related skin damage. Oral use is promoted for digestive conditions, though topical applications have more research support.",
"how": "Apply gel from the inner leaf of the aloe plant (or a commercially prepared aloe gel) directly to the affected skin area. Topical use is the application with the most research support; oral aloe products carry more serious risks and should not be used without consulting a healthcare provider.",
"evidence": "Research suggests topical aloe gel may speed burn healing and reduce burn-related pain. Two small studies indicate aloe gel, combined with other treatments, may improve acne; evidence for other skin uses is limited.",
"cautions": "Topical use is generally well tolerated, though occasional burning or itching may occur. Oral use carries more serious risks: the latex can cause abdominal pain and diarrhea, oral extracts have been linked to cases of acute hepatitis, and animal studies associated non-decolorized extracts with gastrointestinal cancer. Likely unsafe during pregnancy; may interact with medications such as digoxin.",
"sourceUrl": "https://www.nccih.nih.gov/health/aloe-vera"
},
{
"slug": "boswellia",
"name": "Boswellia",
"commonNames": ["Boswellia serrata", "Indian frankincense"],
"conditions": ["muscle-joint-pain"],
"uses": "Boswellia resin extract is traditionally used to reduce inflammation and pain, and is promoted as a dietary supplement to support joint health and mobility, particularly for osteoarthritis.",
"how": "Boswellia is taken orally as a dietary supplement. Clinical trials have used doses up to 1,000 mg daily for up to 6 months; follow the product label and consult a healthcare provider before starting.",
"evidence": "Some studies suggest oral boswellia may help reduce inflammation and pain associated with osteoarthritis, but larger rigorous trials are needed; topical use lacks sufficient evidence of effectiveness.",
"cautions": "Extracts up to 1,000 mg daily have been used safely in clinical trials lasting up to 6 months. Consult a healthcare provider before use, especially if pregnant, breastfeeding, managing asthma, or taking medications, as interactions remain unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/boswellia"
},
{
"slug": "bromelain",
"name": "Bromelain",
"commonNames": ["pineapple enzyme", "Ananas comosus extract"],
"conditions": ["muscle-joint-pain", "nasal-congestion"],
"uses": "Bromelain, an enzyme from pineapple, is promoted for reducing postoperative pain and swelling, sinusitis, osteoarthritis, and exercise-induced muscle soreness. A topical formulation has FDA approval for debridement of severe burns.",
"evidence": "Some studies suggest oral bromelain may reduce certain symptoms after wisdom tooth surgery; evidence for sinusitis is insufficient. The topical formulation has been successfully used by health professionals for burn debridement as an alternative to surgical debridement.",
"cautions": "Oral bromelain is generally well tolerated; the most common side effects are stomach upset and diarrhea. Talk with a healthcare provider before use alongside any medications, as harmful interactions are possible. Safety during pregnancy and breastfeeding is unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/bromelain"
},
{
"slug": "butterbur",
"name": "Butterbur",
"commonNames": ["Petasites hybridus"],
"conditions": ["headache", "allergic-reaction"],
"uses": "Butterbur root extract is used for migraine prevention and for reducing symptoms of allergic rhinitis (hay fever). It has been studied for reducing migraine frequency in both adults and children.",
"how": "Butterbur is taken orally as a root extract for migraine prevention or as a leaf extract for allergic rhinitis symptoms. Use only products that are certified and labeled as free of pyrrolizidine alkaloids (PA-free), as the untreated plant contains compounds that can damage the liver.",
"evidence": "Studies of a butterbur root extract suggest it may reduce migraine frequency; a leaf extract may help with allergic rhinitis symptoms. However, the American Academy of Neurology withdrew its 2012 recommendation in 2015 due to safety concerns.",
"cautions": "The plant naturally contains pyrrolizidine alkaloids (PAs) that can damage the liver and lungs and may cause cancer; only PA-free certified products should be used. Even PA-free products have been linked to rare cases of liver injury. Side effects include belching, diarrhea, drowsiness, rash, and stomach upset. Avoid during pregnancy; people allergic to ragweed or related plants are at higher risk of reactions.",
"sourceUrl": "https://www.nccih.nih.gov/health/butterbur"
},
{
"slug": "chamomile",
"name": "Chamomile",
"commonNames": ["Matricaria chamomilla", "German chamomile", "Chamaemelum nobile"],
"conditions": ["sleeplessness", "indigestion", "common-cold", "sore-throat", "skin-rash-itch"],
"uses": "Chamomile is promoted for insomnia, indigestion, anxiety, the common cold, and infant colic; it is also used topically for skin conditions and as a mouthwash for oral inflammation.",
"how": "Chamomile is commonly consumed as a tea (considered safe in amounts typically found in teas), taken as an oral supplement, applied topically to skin, or used as a mouthwash for oral inflammation. Consult a healthcare provider before using oral supplements rather than tea.",
"evidence": "Evidence is limited: some preliminary studies suggest chamomile supplements may help with anxiety, and combination products may help childhood diarrhea and infant colic. A 2019 review found minimal evidence for insomnia, with one study showing no benefit. Evidence for cold, sore throat, and skin uses in people is insufficient.",
"cautions": "Generally considered safe in typical amounts. Side effects may include nausea, dizziness, and allergic reactions, including severe hypersensitivity; risk is higher for those sensitive to ragweed, chrysanthemums, marigolds, or daisies. May interact with blood thinners, birth control pills, sedatives, and liver-metabolized drugs. Safety during pregnancy and breastfeeding is unknown.",
"sourceUrl": "https://www.nccih.nih.gov/health/chamomile"
},
{
"slug": "echinacea",
"name": "Echinacea",
"commonNames": ["Echinacea purpurea", "purple coneflower"],
"conditions": ["common-cold"],
"uses": "Echinacea is primarily marketed for the common cold and upper respiratory tract infections, based on the idea that it may support immune system function.",
"evidence": "Studies indicate that taking echinacea may slightly reduce the chances of catching a cold, though evidence regarding whether it shortens cold duration is inconclusive. Evidence for other conditions, including eczema, is unclear.",
"cautions": "E. purpurea extracts appear likely safe for short periods in adults; allergic reactions can occur, with digestive symptoms most common. Children may experience rashes potentially linked to allergic reactions. Theoretical interactions with immunosuppressants and certain other drugs; consult a healthcare provider before use with medications. Limited data on safety in early pregnancy.",
"sourceUrl": "https://www.nccih.nih.gov/health/echinacea"
},
{
"slug": "elderberry",
"name": "Elderberry",
"commonNames": ["Sambucus nigra"],
"conditions": ["common-cold"],
"uses": "Elderberry has been used in folk medicine to treat colds and flu, and is promoted as a dietary supplement for colds, flu, and other upper respiratory infections.",
"how": "Elderberry is used as a prepared dietary supplement (syrup, capsule, or lozenge) rather than raw fruit. Never eat raw or unripe elderberries — they contain cyanide-producing substances that cause nausea, vomiting, and severe diarrhea; cooking the berries eliminates this toxin.",
"evidence": "A small number of studies suggest elderberry may relieve symptoms of flu, colds, or other upper respiratory infections; however, the overall evidence is limited and insufficient for most other claimed benefits.",
"cautions": "Raw or unripe elderberries contain cyanide-producing substances that can cause nausea, vomiting, and severe diarrhea; cooking eliminates this toxin. Consult a healthcare provider before use, especially if taking medications. Limited safety data for pregnancy and breastfeeding.",
"sourceUrl": "https://www.nccih.nih.gov/health/elderberry"
},
{
"slug": "feverfew",
"name": "Feverfew",
"commonNames": ["Tanacetum parthenium"],
"conditions": ["headache"],
"uses": "Feverfew is promoted for migraine headache prevention and for minor head and tension pain. Topically, it is marketed for itching and skin irritation.",
"evidence": "A 2020 systematic review of seven migraine studies found inconsistent results, though some evidence suggests feverfew may reduce migraine frequency and associated symptoms such as nausea and light sensitivity. There is little or no evidence supporting feverfew for other health conditions.",
"cautions": "Side effects include nausea, digestive issues, bloating, and mouth sores from chewing fresh leaves. People sensitive to ragweed may experience allergic reactions. Feverfew may slow blood clotting and should be stopped at least 2 weeks before scheduled surgery. It may interact with migraine medications and should be avoided during pregnancy due to potential effects on uterine contractions.",
"sourceUrl": "https://www.nccih.nih.gov/health/feverfew"
},
{
"slug": "ginger",
"name": "Ginger",
"commonNames": ["Zingiber officinale"],
"conditions": ["nausea-vomiting", "menstrual-cramps", "indigestion"],
"uses": "Ginger is traditionally and commonly used for nausea and vomiting, indigestion, menstrual cramps, and osteoarthritis.",
"evidence": "Research shows ginger may be helpful for nausea and vomiting associated with pregnancy. Studies suggest ginger dietary supplements might be helpful for reducing the severity of menstrual cramps and for knee osteoarthritis symptoms; effectiveness for chemotherapy- or post-surgery-related nausea remains uncertain.",
"cautions": "Side effects when taken orally include abdominal discomfort, heartburn, diarrhea, and mouth and throat irritation. Possible interactions with medications exist; consult a healthcare provider before use, especially during pregnancy or breastfeeding.",
"sourceUrl": "https://www.nccih.nih.gov/health/ginger"
},
{
"slug": "goldenseal",
"name": "Goldenseal",
"commonNames": ["Hydrastis canadensis"],
"conditions": ["common-cold", "diarrhea", "wounds-cuts"],
"uses": "Goldenseal is marketed for the common cold and upper respiratory infections, diarrhea, constipation, and other digestive conditions. Historically, Native Americans used it for digestive disorders, wounds, and skin conditions.",
"evidence": "There is not enough evidence to determine whether goldenseal is useful for any health condition; no rigorous studies have been done in people. Very little of the active compound berberine is absorbed when goldenseal is taken orally, making findings from berberine studies potentially inapplicable.",
"cautions": "Short-term use at approximately 3 grams daily appears to be without serious adverse effects, though longer-term safety is uncertain. Goldenseal significantly affects drug metabolism; one study found it decreased metformin levels by about 25%. Some commercial products contain unlisted ingredients or substitute herbs. Avoid during pregnancy, breastfeeding, and in infants due to potential harm from berberine.",
"sourceUrl": "https://www.nccih.nih.gov/health/goldenseal"
},
{
"slug": "horse-chestnut",
"name": "Horse Chestnut",
"commonNames": ["Aesculus hippocastanum"],
"conditions": ["hemorrhoids"],
"uses": "Horse chestnut seed extract is promoted for chronic venous insufficiency (leg pain and swelling from poor circulation) and has historically been used for hemorrhoids, arthritis, and menstrual cramps.",
"how": "Use only standardized horse chestnut seed extract (a dietary supplement) — never raw seeds, bark, flowers, or leaves, which are toxic when taken orally. Research has used standardized extract for up to 12 weeks; follow the product label and consult a healthcare provider before use.",
"evidence": "A 2012 systematic review found horse chestnut seed extract can improve symptoms of chronic venous insufficiency, comparable to compression stockings in one study; however, more rigorous trials are needed. Evidence for other uses, including hemorrhoids, is insufficient.",
"cautions": "Raw seeds, bark, flowers, and leaves are unsafe to take orally due to toxic components; only standardized extracts with the toxin removed should be used, and research shows safety for up to 12 weeks. Side effects include dizziness, digestive upset, headache, and itching. Safety during pregnancy and breastfeeding is unknown; consult a healthcare provider before use.",
"sourceUrl": "https://www.nccih.nih.gov/health/horse-chestnut"
},
{
"slug": "lavender",
"name": "Lavender",
"commonNames": ["Lavandula angustifolia"],
"conditions": ["sleeplessness"],
"uses": "Lavender is promoted as an oral supplement and aromatherapy agent for calming anxiety, stress, and sleep difficulties; it is also used topically and in aromatherapy for depression symptoms and pain.",
"how": "Lavender is used as an oral supplement (capsule or tea), as an aromatherapy oil (inhaled or diffused), or applied topically to skin. Oral lavender oil products have been studied for anxiety; aromatherapy involves inhaling the scent from a diffuser or a few drops on a cloth.",
"evidence": "Studies suggest oral lavender oil might be beneficial for anxiety, including anxiety with co-occurring depression; evidence for sleep improvement is insufficient, and it is unclear whether aromatherapy with lavender benefits anxiety, stress, or depression.",
"cautions": "Oral lavender products may cause diarrhea, headache, nausea, or burping. Topical application can trigger allergic skin reactions. Potential interactions with sedative drugs warrant discussion with a healthcare provider. Safety during pregnancy and breastfeeding is unknown.",
"sourceUrl": "https://www.nccih.nih.gov/health/lavender"
},
{
"slug": "licorice-root",
"name": "Licorice Root",
"commonNames": ["Glycyrrhiza glabra"],
"conditions": ["canker-sores", "sore-throat", "burns", "skin-rash-itch"],
"uses": "Licorice root is promoted for digestive and respiratory support and is used topically for skin conditions. Research has focused on mouth rinses for canker sores, gargles or lozenges for sore throat prevention, and topical gels for eczema and burn healing.",
"how": "For canker sores, licorice is used as a mouth rinse or gargle; for sore throat prevention around surgery, as a gargle or lozenge; for eczema or burn healing, as a topical gel applied to the skin. Avoid long-term or high-dose oral use due to serious cardiovascular risks.",
"evidence": "Preliminary studies suggest licorice mouth rinses may reduce canker sore pain and size, and lozenges or gargles may prevent sore throat after surgical intubation. Topical gels show some promise for eczema symptoms and burn healing, though more research is needed. There is not enough high-quality evidence to support its use for most conditions.",
"cautions": "Licorice contains glycyrrhizin, which can cause serious adverse effects including irregular heartbeat and cardiac arrest, especially with long-term or large-dose use. Even small amounts can be risky for people with high blood pressure, high salt intake, or heart or kidney conditions. Large amounts during pregnancy increase miscarriage risk and are considered unsafe. Interactions with corticosteroids have been documented; consult a healthcare provider before use.",
"sourceUrl": "https://www.nccih.nih.gov/health/licorice-root"
},
{
"slug": "passionflower",
"name": "Passionflower",
"commonNames": ["Passiflora incarnata"],
"conditions": ["sleeplessness"],
"uses": "Passionflower is marketed as a dietary supplement for anxiety, sleep problems, and stress.",
"how": "Passionflower is taken as a tea (studied for up to 7 nights) or as an oral extract supplement (studied for up to 8 weeks). Follow package directions and consult a healthcare provider before use, especially if you take other medications or are scheduled for surgery.",
"evidence": "Some research suggests oral passionflower may help reduce anxiety symptoms and improve total sleep time in adults with insomnia, though findings on falling or staying asleep are mixed and conclusions are not definite.",
"cautions": "Taking passionflower alongside anesthesia or other surgical medications may slow the nervous system too much; avoid use before surgery. Should not be used during pregnancy as it may induce uterine contractions. Possible side effects include drowsiness, dizziness, and confusion. Discuss with a healthcare provider before combining with medications.",
"sourceUrl": "https://www.nccih.nih.gov/health/passionflower"
},
{
"slug": "peppermint-oil",
"name": "Peppermint Oil",
"commonNames": ["Mentha x piperita"],
"conditions": ["headache", "nausea-vomiting", "indigestion"],
"uses": "Peppermint oil is promoted for irritable bowel syndrome, indigestion, headaches, muscle tension, and nausea. Topical application is used for tension headaches, and inhaled peppermint oil is used for nausea.",
"how": "For IBS, peppermint oil is taken as enteric-coated capsules (which reduce heartburn compared to plain capsules). For tension headaches, apply peppermint oil topically to the skin. For nausea, peppermint oil is inhaled as aromatherapy.",
"evidence": "A 2022 review found peppermint oil was better than placebo at improving overall IBS symptoms and abdominal pain. A 2024 review concluded that inhaling peppermint oil was particularly effective at reducing nausea and vomiting in patients with cancer. Evidence suggests potential benefit for tension headaches when applied topically, though peppermint oil taken alone may worsen indigestion in some people.",
"cautions": "Oral side effects may include heartburn, nausea, abdominal pain, and dry mouth; enteric-coated capsules reduce heartburn risk. Never apply topically to infants' or young children's faces due to menthol's respiratory effects. Skin irritation is possible with topical use. Consult a healthcare provider before use with medications, as interactions may occur.",
"sourceUrl": "https://www.nccih.nih.gov/health/peppermint-oil"
},
{
"slug": "tea-tree-oil",
"name": "Tea Tree Oil",
"commonNames": ["Melaleuca alternifolia"],
"conditions": ["fungal-infection", "acne", "wounds-cuts", "insect-bites-stings", "burns"],
"uses": "Tea tree oil is marketed for external use including acne, athlete's foot, toenail fungus, and lice. Traditional Aboriginal uses included treating wounds, burns, and insect bites.",
"how": "Apply tea tree oil topically to the affected skin area only — for acne or athlete's foot, use a product containing tea tree oil as directed on the label. Never swallow tea tree oil; oral ingestion can cause serious symptoms including confusion, unsteadiness, and coma.",
"evidence": "A small amount of research suggests tea tree oil might be helpful for acne and athlete's foot (fungal infection), though more studies are needed. Evidence for toenail fungus, lice, eyelid inflammation, and oral health uses is insufficient or uncertain. Traditional wound, burn, and insect bite uses lack rigorous clinical research.",
"cautions": "Critical warning: tea tree oil must not be swallowed — oral ingestion can cause serious symptoms including confusion, unsteadiness, inability to walk, and coma. Topical use is generally safe for most adults, though some experience skin redness or irritation. Older products or those exposed to heat, light, or air may increase reaction risk. Products appear safe during pregnancy and breastfeeding when used topically only.",
"sourceUrl": "https://www.nccih.nih.gov/health/tea-tree-oil"
},
{
"slug": "turmeric",
"name": "Turmeric",
"commonNames": ["Curcuma longa", "curcumin"],
"conditions": ["muscle-joint-pain", "indigestion", "skin-rash-itch"],
"uses": "Turmeric is promoted for osteoarthritis, itching, depression, allergies, and high cholesterol; topical applications target joint pain. Historically, it was used in traditional medicine for indigestion, colds, skin infections, and liver disease.",
"how": "Turmeric is taken orally as a dietary supplement or applied topically to the skin. Conventionally formulated oral turmeric or curcumin supplements are considered likely safe in recommended amounts for up to 2 to 3 months; follow the product label and consult a healthcare provider before use.",
"evidence": "For osteoarthritis, initial evidence is positive for knee pain relief and joint function, though higher-quality research is needed. For most other claimed uses, researchers cannot definitively confirm turmeric's effectiveness, and overall evidence remains inconclusive.",
"cautions": "Standard turmeric formulations are likely safe in recommended amounts for up to 23 months. Common side effects include nausea, vomiting, acid reflux, and digestive issues. Enhanced bioavailability products have caused liver damage in some users; symptoms include fatigue, dark urine, and jaundice. Use during pregnancy may be unsafe; breastfeeding safety is unclear. Consult a healthcare provider before use with medications.",
"sourceUrl": "https://www.nccih.nih.gov/health/turmeric"
},
{
"slug": "valerian",
"name": "Valerian",
"commonNames": ["Valeriana officinalis"],
"conditions": ["sleeplessness"],
"uses": "Valerian is promoted for insomnia, anxiety, stress, and depression; historically, it was also used for migraine, fatigue, and stomach issues.",
"how": "Valerian is taken orally as a dietary supplement; clinical studies have used doses of 300 to 600 mg daily for up to 6 weeks. Follow the product label and consult a healthcare provider before use, particularly if combining with sedatives or alcohol.",
"evidence": "Evidence is limited and inconsistent. The American Academy of Sleep Medicine recommended against using valerian for chronic insomnia in adults (2017). Studies on anxiety, menstrual cramps, and other conditions show inadequate evidence of benefit.",
"cautions": "Appears generally safe for short-term use at 300600 mg daily for up to 6 weeks; long-term safety is unknown. Common side effects include headache, stomach upset, mental dullness, and vivid dreams. Abrupt discontinuation after long-term use may cause withdrawal symptoms including anxiety and insomnia. Avoid combining with alcohol or sedatives. Safety during pregnancy and breastfeeding is unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/valerian"
}
]
}