- The medicine database is now much bigger and has a real update path.

Added:
Live source updater for RxNorm, DailyMed, and openFDA.
Admin dashboard panel to refresh medicine sources.
CLI updater for periodic jobs: 
[scripts/update_medicine_catalog.php](/Users/tyemeclifford/Documents/GH/workout/scripts/update_medicine_catalog.php)
Update history table: medication_source_updates.
Curl fallback for external fetches when PHP stream DNS fails.
README instructions for periodic updates.
I also ran the imports:
Current medication catalog count: 6,002.
Latest source imports:RxNorm: 3,875 inserted, 1,125 updated.
openFDA: 912 inserted, 88 updated.
DailyMed: 95 inserted.

Periodic update command:
/Users/tyemeclifford/frankenphp php-cli 
scripts/update_medicine_catalog.php 5000 all
This commit is contained in:
Ty Clifford
2026-06-30 15:29:57 -04:00
parent 17ebaf21a3
commit 96de47be43
10 changed files with 594 additions and 14 deletions
+324
View File
@@ -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();