diff --git a/README.md b/README.md index fdb6118..a9e66a6 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,13 @@ SQLite is created automatically at: data/health_tracker.sqlite ``` -The app seeds sample profiles, medical terminology, condition summaries, a medication catalog, NIH/NLM study-link categories, medication logs, vitals, labs, symptoms, allergies, appointments, immunizations, and the older optional wellness/fitness data. +The app seeds sample profiles, medical terminology, condition summaries, an expanded medication catalog, NIH/NLM study-link categories, medication logs, vitals, labs, symptoms, allergies, appointments, immunizations, and the older optional wellness/fitness data. ## Included - Multi-user profiles with separate records and theme preferences. - Admin dashboard for creating, editing, activating/deactivating, and deleting user profiles. +- Admin medicine-source updater for refreshing the catalog from RxNorm, DailyMed, and openFDA. - First user created in a database is automatically promoted to `admin`; later users default to `member`. - Daily, weekly, and monthly views for medical logs. - Medication logging by date, dose, route, frequency, status, prescriber, and notes. @@ -48,14 +49,31 @@ The app seeds sample profiles, medical terminology, condition summaries, a medic - Lab result records with units, reference ranges, clinician/source notes, and collection dates. - Symptom journal, allergy list, appointments, and immunization records. - Medical terminology and condition database with source links. -- Medicine catalog with RxNorm, MedlinePlus, PubMed, and ClinicalTrials.gov links. +- Searchable medicine catalog by generic name, active ingredient, chemical name, common brand names, and generic/alternate names. +- Medicine links to RxNorm, DailyMed, PubChem, MedlinePlus, PubMed, and ClinicalTrials.gov. - NIH/NLM research-link categories related to the listed medicines. - Neon dark theme by default, light mode option, and green, blue, red, pink, and orange accents. - Optional legacy fitness, nutrition, and supplement tracking remains available as wellness context. +## Medicine Catalog Updates + +The catalog ships with a curated starter set, the current SQLite file includes thousands of imported medicine concepts, and it can be refreshed from public medicine sources. + +From the admin dashboard, use the medicine source updater and choose `All sources`, `RxNorm`, `DailyMed`, or `openFDA`. + +From the command line: + +```sh +/Users/tyemeclifford/frankenphp php-cli scripts/update_medicine_catalog.php 1000 all +``` + +For a periodic cron-style update, run the same command nightly or weekly. The first argument is the max rows per source, from `25` to `5000`; the second argument is `all`, `RxNorm`, `DailyMed`, or `openFDA`. + ## Reference Sources - RxNorm and RxNav from the U.S. National Library of Medicine: `https://www.nlm.nih.gov/research/umls/rxnorm/index.html` +- DailyMed label search from the U.S. National Library of Medicine: `https://dailymed.nlm.nih.gov/dailymed/` +- PubChem chemical/compound search from NCBI: `https://pubchem.ncbi.nlm.nih.gov/` - MedlinePlus condition and medication pages: `https://medlineplus.gov/` - PubMed search links for medicine and condition research: `https://pubmed.ncbi.nlm.nih.gov/` - ClinicalTrials.gov search links for study discovery: `https://clinicaltrials.gov/` diff --git a/app/bootstrap.php b/app/bootstrap.php index 7fa0cb8..da6a04d 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -23,6 +23,7 @@ function app_db(): PDO $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec('PRAGMA busy_timeout = 5000'); app_init_db($pdo); @@ -315,6 +316,16 @@ CREATE TABLE IF NOT EXISTS research_studies ( created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS medication_source_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + status TEXT NOT NULL, + imported_count INTEGER NOT NULL DEFAULT 0, + updated_count INTEGER NOT NULL DEFAULT 0, + message TEXT DEFAULT '', + created_at TEXT NOT NULL +); + CREATE INDEX IF NOT EXISTS idx_workouts_user_date ON workouts(user_id, scheduled_on); CREATE INDEX IF NOT EXISTS idx_nutrition_logs_user_date ON nutrition_logs(user_id, logged_on); CREATE INDEX IF NOT EXISTS idx_supplement_logs_user_date ON supplement_logs(user_id, taken_on); @@ -328,6 +339,7 @@ CREATE INDEX IF NOT EXISTS idx_appointments_user_date ON appointments(user_id, a CREATE INDEX IF NOT EXISTS idx_immunization_records_user_date ON immunization_records(user_id, administered_on); CREATE INDEX IF NOT EXISTS idx_medical_terms_term ON medical_terms(term); CREATE INDEX IF NOT EXISTS idx_medication_catalog_generic ON medication_catalog(generic_name); +CREATE INDEX IF NOT EXISTS idx_medication_source_updates_created ON medication_source_updates(created_at); SQL); migrate_users_table($pdo); @@ -611,6 +623,12 @@ function seed_expanded_medication_catalog(PDO $pdo): void { $created = app_now(); $rows = expanded_medication_catalog_rows(); + $existingCount = (int) $pdo->query('SELECT COUNT(*) FROM medication_catalog')->fetchColumn(); + $needsSearchText = (int) $pdo->query("SELECT COUNT(*) FROM medication_catalog WHERE search_terms IS NULL OR search_terms = ''")->fetchColumn(); + $needsChemicalNames = (int) $pdo->query("SELECT COUNT(*) FROM medication_catalog WHERE chemical_name IS NULL OR chemical_name = ''")->fetchColumn(); + if ($existingCount >= count($rows) && $needsSearchText === 0 && $needsChemicalNames === 0) { + return; + } $select = $pdo->prepare('SELECT id FROM medication_catalog WHERE lower(generic_name) = lower(?) LIMIT 1'); $insert = $pdo->prepare( @@ -1705,6 +1723,312 @@ function library_search_payload(array $query): array ]; } +function http_json(string $url, int $timeout = 20): ?array +{ + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => $timeout, + 'header' => "Accept: application/json\r\nUser-Agent: NeonMedicalTracker/1.0\r\n", + ], + ]); + $body = @file_get_contents($url, false, $context); + if (($body === false || $body === '') && function_exists('shell_exec')) { + $curl = trim((string) shell_exec('command -v curl 2>/dev/null')); + if ($curl === '' && is_executable('/usr/bin/curl')) { + $curl = '/usr/bin/curl'; + } + if ($curl !== '') { + $command = escapeshellarg($curl) + . ' -fsSL --max-time ' . (int) $timeout + . ' -H ' . escapeshellarg('Accept: application/json') + . ' -A ' . escapeshellarg('NeonMedicalTracker/1.0') + . ' ' . escapeshellarg($url) + . ' 2>/dev/null'; + $body = shell_exec($command); + } + } + if (!is_string($body) || $body === '') { + return null; + } + $json = json_decode($body, true); + return is_array($json) ? $json : null; +} + +function medication_import_row(string $name, string $source, string $brandNames = '', string $className = 'Imported medicine concept'): array +{ + $cleanName = preg_replace('/\s+/', ' ', trim($name)); + if ($cleanName === '') { + return []; + } + + $sourceLabel = match ($source) { + 'RxNorm' => 'RxNorm / NLM', + 'DailyMed' => 'DailyMed / NLM', + 'openFDA' => 'openFDA / FDA', + default => $source, + }; + + return [ + 'generic_name' => strtolower($cleanName), + 'active_ingredient' => $cleanName, + 'chemical_name' => $cleanName, + 'brand_names' => $brandNames, + 'generic_brands' => $cleanName, + 'class_name' => $className, + 'drug_family' => 'Imported reference', + 'common_use' => "Imported from {$sourceLabel}. Review official labels and clinician guidance before use.", + 'tracking_note' => 'Use this entry as a reference/search starting point; verify dose, indication, and safety details with official labeling or a clinician.', + 'rxnorm_url' => medication_source_url('https://mor.nlm.nih.gov/RxNav/search?searchBy=String&searchTerm=', $cleanName), + 'medlineplus_url' => 'https://medlineplus.gov/druginformation.html', + 'pubchem_url' => medication_source_url('https://pubchem.ncbi.nlm.nih.gov/#query=', $cleanName), + 'dailymed_url' => medication_source_url('https://dailymed.nlm.nih.gov/dailymed/search.cfm?query=', $cleanName), + 'pubmed_url' => medication_source_url('https://pubmed.ncbi.nlm.nih.gov/?term=', $cleanName . ' medication'), + 'clinicaltrials_url' => medication_source_url('https://clinicaltrials.gov/search?term=', $cleanName), + 'source_name' => $sourceLabel, + 'search_terms' => implode(' ', [$cleanName, $brandNames, $className, $sourceLabel]), + ]; +} + +function fetch_rxnorm_medication_rows(int $limit): array +{ + $rows = []; + foreach (['IN', 'PIN'] as $tty) { + $json = http_json('https://rxnav.nlm.nih.gov/REST/allconcepts.json?tty=' . $tty); + $concepts = $json['minConceptGroup']['minConcept'] ?? []; + if (!is_array($concepts)) { + continue; + } + + foreach ($concepts as $concept) { + if (count($rows) >= $limit) { + break 2; + } + $name = (string) ($concept['name'] ?? ''); + $row = medication_import_row($name, 'RxNorm'); + if ($row) { + $rows[] = $row; + } + } + } + return $rows; +} + +function fetch_openfda_medication_rows(int $limit): array +{ + $url = 'https://api.fda.gov/drug/label.json?count=openfda.generic_name.exact&limit=' . $limit; + $json = http_json($url); + $results = $json['results'] ?? []; + if (!is_array($results)) { + return []; + } + + $rows = []; + foreach ($results as $result) { + if (count($rows) >= $limit) { + break; + } + $term = (string) ($result['term'] ?? ''); + $row = medication_import_row($term, 'openFDA'); + if ($row) { + $rows[] = $row; + } + } + return $rows; +} + +function fetch_dailymed_medication_rows(int $limit): array +{ + $url = 'https://dailymed.nlm.nih.gov/dailymed/services/v2/drugnames.json'; + $json = http_json($url); + $data = $json['data'] ?? $json['drugNames'] ?? $json['results'] ?? []; + if (!is_array($data)) { + return []; + } + + $rows = []; + foreach ($data as $item) { + if (count($rows) >= $limit) { + break; + } + $name = is_array($item) + ? (string) ($item['drug_name'] ?? $item['name'] ?? $item['term'] ?? '') + : (string) $item; + $row = medication_import_row($name, 'DailyMed'); + if ($row) { + $rows[] = $row; + } + } + return $rows; +} + +function merge_text_values(string $current, string $incoming): string +{ + $parts = []; + foreach (explode(',', $current . ',' . $incoming) as $part) { + $value = trim($part); + if ($value === '') { + continue; + } + $parts[strtolower($value)] = $value; + } + return implode(', ', array_values($parts)); +} + +function upsert_imported_medication_rows(PDO $pdo, array $rows): array +{ + $created = app_now(); + $select = $pdo->prepare('SELECT * FROM medication_catalog WHERE lower(generic_name) = lower(?) LIMIT 1'); + $insert = $pdo->prepare( + "INSERT INTO medication_catalog + (generic_name, active_ingredient, chemical_name, brand_names, generic_brands, class_name, drug_family, + common_use, tracking_note, rxnorm_url, medlineplus_url, pubchem_url, dailymed_url, pubmed_url, + clinicaltrials_url, source_name, search_terms, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + $update = $pdo->prepare( + "UPDATE medication_catalog + SET active_ingredient = ?, chemical_name = ?, brand_names = ?, generic_brands = ?, + class_name = ?, drug_family = ?, common_use = ?, tracking_note = ?, rxnorm_url = ?, + medlineplus_url = ?, pubchem_url = ?, dailymed_url = ?, pubmed_url = ?, + clinicaltrials_url = ?, source_name = ?, search_terms = ? + WHERE id = ?" + ); + + $seen = []; + $inserted = 0; + $updated = 0; + foreach ($rows as $row) { + if (!$row || empty($row['generic_name'])) { + continue; + } + $key = strtolower((string) $row['generic_name']); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $select->execute([$row['generic_name']]); + $existing = $select->fetch(); + if (!$existing) { + $insert->execute([ + $row['generic_name'], + $row['active_ingredient'], + $row['chemical_name'], + $row['brand_names'], + $row['generic_brands'], + $row['class_name'], + $row['drug_family'], + $row['common_use'], + $row['tracking_note'], + $row['rxnorm_url'], + $row['medlineplus_url'], + $row['pubchem_url'], + $row['dailymed_url'], + $row['pubmed_url'], + $row['clinicaltrials_url'], + $row['source_name'], + $row['search_terms'], + $created, + ]); + $inserted++; + continue; + } + + $next = []; + foreach (['active_ingredient', 'chemical_name', 'class_name', 'drug_family', 'common_use', 'tracking_note', 'rxnorm_url', 'medlineplus_url', 'pubchem_url', 'dailymed_url', 'pubmed_url', 'clinicaltrials_url'] as $field) { + $next[$field] = trim((string) ($existing[$field] ?? '')) !== '' ? $existing[$field] : $row[$field]; + } + $next['brand_names'] = merge_text_values((string) ($existing['brand_names'] ?? ''), (string) $row['brand_names']); + $next['generic_brands'] = merge_text_values((string) ($existing['generic_brands'] ?? ''), (string) $row['generic_brands']); + $next['source_name'] = merge_text_values((string) ($existing['source_name'] ?? ''), (string) $row['source_name']); + $next['search_terms'] = trim((string) ($existing['search_terms'] ?? '') . ' ' . (string) $row['search_terms']); + + $update->execute([ + $next['active_ingredient'], + $next['chemical_name'], + $next['brand_names'], + $next['generic_brands'], + $next['class_name'], + $next['drug_family'], + $next['common_use'], + $next['tracking_note'], + $next['rxnorm_url'], + $next['medlineplus_url'], + $next['pubchem_url'], + $next['dailymed_url'], + $next['pubmed_url'], + $next['clinicaltrials_url'], + $next['source_name'], + $next['search_terms'], + (int) $existing['id'], + ]); + $updated++; + } + + return ['inserted' => $inserted, 'updated' => $updated, 'seen' => count($seen)]; +} + +function record_medication_source_update(PDO $pdo, string $source, string $status, int $inserted, int $updated, string $message): void +{ + $stmt = $pdo->prepare( + "INSERT INTO medication_source_updates + (source, status, imported_count, updated_count, message, created_at) + VALUES (?, ?, ?, ?, ?, ?)" + ); + $stmt->execute([$source, $status, $inserted, $updated, $message, app_now()]); +} + +function refresh_medication_sources(array $data): array +{ + $pdo = app_db(); + if (array_key_exists('acting_user_id', $data) || array_key_exists('user_id', $data)) { + require_admin($pdo, $data); + } + + $limit = max(25, min(5000, int_value($data, 'limit', 1000))); + $sources = [ + 'RxNorm' => 'fetch_rxnorm_medication_rows', + 'DailyMed' => 'fetch_dailymed_medication_rows', + 'openFDA' => 'fetch_openfda_medication_rows', + ]; + $requestedSource = text_value($data, 'source', 'all'); + if ($requestedSource !== 'all') { + $sources = array_intersect_key($sources, [$requestedSource => true]); + } + + $summary = []; + $totalInserted = 0; + $totalUpdated = 0; + foreach ($sources as $source => $fetcher) { + try { + $rows = $fetcher($limit); + if (!$rows) { + record_medication_source_update($pdo, $source, 'empty', 0, 0, 'No rows returned. The source may be unavailable or rate-limited.'); + $summary[] = ['source' => $source, 'status' => 'empty', 'inserted' => 0, 'updated' => 0, 'message' => 'No rows returned.']; + continue; + } + $result = upsert_imported_medication_rows($pdo, $rows); + $totalInserted += $result['inserted']; + $totalUpdated += $result['updated']; + $message = "Fetched {$result['seen']} unique rows."; + record_medication_source_update($pdo, $source, 'ok', $result['inserted'], $result['updated'], $message); + $summary[] = ['source' => $source, 'status' => 'ok', 'inserted' => $result['inserted'], 'updated' => $result['updated'], 'message' => $message]; + } catch (Throwable $exception) { + record_medication_source_update($pdo, $source, 'error', 0, 0, $exception->getMessage()); + $summary[] = ['source' => $source, 'status' => 'error', 'inserted' => 0, 'updated' => 0, 'message' => $exception->getMessage()]; + } + } + + return [ + 'inserted' => $totalInserted, + 'updated' => $totalUpdated, + 'catalog_count' => (int) db_value($pdo, 'SELECT COUNT(*) FROM medication_catalog'), + 'sources' => $summary, + 'recent_updates' => db_all($pdo, 'SELECT * FROM medication_source_updates ORDER BY created_at DESC, id DESC LIMIT 10'), + ]; +} + function create_user(array $data): array { $pdo = app_db(); diff --git a/data/health_tracker.sqlite b/data/health_tracker.sqlite index 2fe6a18..bb0884e 100644 Binary files a/data/health_tracker.sqlite and b/data/health_tracker.sqlite differ diff --git a/public/admin.php b/public/admin.php index 128a08a..5a9afec 100644 --- a/public/admin.php +++ b/public/admin.php @@ -31,6 +31,21 @@ require __DIR__ . '/partials/site-header.php';