From c7c5495fb52df2c3ad3c7328d84b1126cbfd2cec Mon Sep 17 00:00:00 2001 From: Kim Hoppenworth Date: Fri, 3 Oct 2025 12:21:40 -0400 Subject: [PATCH] Implement RBAC login and theme updates --- README.md | 70 ++++++++- app/Auth.php | 60 +++++++ app/InteractionRepository.php | 116 ++++++++++++++ app/InteractionService.php | 152 ++++++++++++++++++ app/bootstrap.php | 28 ++++ app/functions.php | 87 +++++++++++ config/database.php | 9 ++ database/sample_data.sql | 10 ++ database/schema.sql | 24 +++ public/api/fhir/bundle.php | 17 ++ public/assets/custom.css | 126 +++++++++++++++ public/export.php | 27 ++++ public/import.php | 195 +++++++++++++++++++++++ public/index.php | 287 ++++++++++++++++++++++++++++++++++ public/login.php | 105 +++++++++++++ public/logout.php | 13 ++ resources/lang/en.php | 64 ++++++++ resources/lang/fr.php | 64 ++++++++ 18 files changed, 1453 insertions(+), 1 deletion(-) create mode 100644 app/Auth.php create mode 100644 app/InteractionRepository.php create mode 100644 app/InteractionService.php create mode 100644 app/bootstrap.php create mode 100644 app/functions.php create mode 100644 config/database.php create mode 100644 database/sample_data.sql create mode 100644 database/schema.sql create mode 100644 public/api/fhir/bundle.php create mode 100644 public/assets/custom.css create mode 100644 public/export.php create mode 100644 public/import.php create mode 100644 public/index.php create mode 100644 public/login.php create mode 100644 public/logout.php create mode 100644 resources/lang/en.php create mode 100644 resources/lang/fr.php diff --git a/README.md b/README.md index b5925f7..8d8aa71 100644 --- a/README.md +++ b/README.md @@ -1 +1,69 @@ -# OpenRIMS-InteractionModule \ No newline at end of file +# OpenRIMS Interaction Module + +A lightweight LAMP-ready drug interaction knowledge base inspired by [interaktionsdatabasen.dk](https://www.interaktionsdatabasen.dk/). The application exposes the data in multilingual AdminLTE 3.2 UI as well as HL7® FHIR® JSON for interoperability with electronic medicines record systems, supply chain partners, and regulators. + +## Features + +- 🇬🇧/🇫🇷 bilingual interface (English and French) with simple locale switching. +- AdminLTE 3.2 layout themed with PANTONE® 294 and PANTONE® 200 accents, rounded components, and left-hand navigation. +- Drug interaction registry stored in MySQL with ATC classification metadata. +- Role-based access control with secure login (public view, staff export, admin import) and bilingual interface. +- CSV import/export workflows for bulk maintenance of interaction pairs (admin import, staff/export access). +- FHIR `Bundle` endpoint composed of `MedicationKnowledge` resources for system-to-system exchange. +- Sample dataset aligned with ATC coding to bootstrap deployments. + +## Requirements + +- Linux server with Apache 2.4+, PHP 8.0+ and MySQL 8 (standard LAMP stack). +- PHP extensions: `pdo_mysql`, `mbstring`, `json`, `session`. + +## Installation + +1. Clone the repository to your Apache document root. +2. Create a MySQL database and user, then import the schema and sample data: + ```sql + CREATE DATABASE openrims_interactions CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + CREATE USER 'openrims'@'localhost' IDENTIFIED BY 'secret'; + GRANT ALL PRIVILEGES ON openrims_interactions.* TO 'openrims'@'localhost'; + FLUSH PRIVILEGES; + ``` + ```bash + mysql -u openrims -p openrims_interactions < database/schema.sql + mysql -u openrims -p openrims_interactions < database/sample_data.sql + ``` +3. Adjust `config/database.php` to match your database credentials (or provide environment variables `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`). +4. Point Apache to the `public/` directory and enable HTTPS. Example vhost snippet: + ```apache + DocumentRoot /var/www/openrims/public + + AllowOverride All + Require all granted + + ``` +5. Visit the site in a browser. Use the language selector in the header to switch between English and French. + +### Default access accounts + +| Role | Email | Password | Capabilities | +| --- | --- | --- | --- | +| Administrator | `admin@example.com` | `Admin123!` | View, export, import | +| Staff | `staff@example.com` | `Staff123!` | View, export | +| Public | – | – | View only | + +Administrators can create additional users directly in the `users` table. + +## Data Exchange + +- **CSV Export:** `GET /export.php` (requires staff or administrator sign-in; respects `query` and `severity` filters). +- **CSV Import:** `POST /import.php` (administrator sign-in required) with `multipart/form-data` containing a `file` field. Required headers: `drug_a_name, drug_a_atc, drug_b_name, drug_b_atc, severity, description, clinical_management, evidence_level, source_url`. +- **FHIR Bundle:** `GET /api/fhir/bundle.php` returns an `application/fhir+json` bundle of `MedicationKnowledge` resources. + +## Development Notes + +- Business logic lives in `app/InteractionService.php`; persistence is handled by `app/InteractionRepository.php`. +- Translations are stored under `resources/lang`. +- UI customization can be adjusted in `public/assets/custom.css`. + +## License + +MIT License. See [LICENSE](LICENSE). diff --git a/app/Auth.php b/app/Auth.php new file mode 100644 index 0000000..718ee55 --- /dev/null +++ b/app/Auth.php @@ -0,0 +1,60 @@ +pdo = $pdo; + } + + public function attempt(string $email, string $password): bool + { + $statement = $this->pdo->prepare('SELECT id, name, email, password, role FROM users WHERE email = :email LIMIT 1'); + $statement->execute(['email' => $email]); + $user = $statement->fetch(); + + if (!$user) { + return false; + } + + if (!password_verify($password, $user['password'])) { + return false; + } + + $_SESSION['user'] = [ + 'id' => (int) $user['id'], + 'name' => $user['name'], + 'email' => $user['email'], + 'role' => $user['role'], + ]; + + session_regenerate_id(true); + + return true; + } + + public function user(): ?array + { + return $_SESSION['user'] ?? null; + } + + public function check(): bool + { + return isset($_SESSION['user']); + } + + public function hasRole(string ...$roles): bool + { + if (!$this->check()) { + return false; + } + + return in_array($_SESSION['user']['role'], $roles, true); + } + + public function logout(): void + { + unset($_SESSION['user']); + } +} diff --git a/app/InteractionRepository.php b/app/InteractionRepository.php new file mode 100644 index 0000000..78ae708 --- /dev/null +++ b/app/InteractionRepository.php @@ -0,0 +1,116 @@ +pdo->prepare($query); + $statement->execute($params); + + return $statement->fetchAll(); + } + + public function find(int $id): ?array + { + $statement = $this->pdo->prepare('SELECT * FROM interactions WHERE id = :id'); + $statement->execute([':id' => $id]); + $interaction = $statement->fetch(); + + return $interaction ?: null; + } + + public function upsert(array $data): int + { + if (!empty($data['id'])) { + $statement = $this->pdo->prepare('UPDATE interactions SET drug_a_name = :drug_a_name, drug_a_atc = :drug_a_atc, drug_b_name = :drug_b_name, drug_b_atc = :drug_b_atc, severity = :severity, description = :description, clinical_management = :clinical_management, evidence_level = :evidence_level, source_url = :source_url WHERE id = :id'); + $statement->execute([ + ':drug_a_name' => $data['drug_a_name'], + ':drug_a_atc' => $data['drug_a_atc'], + ':drug_b_name' => $data['drug_b_name'], + ':drug_b_atc' => $data['drug_b_atc'], + ':severity' => $data['severity'], + ':description' => $data['description'], + ':clinical_management' => $data['clinical_management'], + ':evidence_level' => $data['evidence_level'], + ':source_url' => $data['source_url'], + ':id' => $data['id'], + ]); + + return (int) $data['id']; + } + + $statement = $this->pdo->prepare('INSERT INTO interactions (drug_a_name, drug_a_atc, drug_b_name, drug_b_atc, severity, description, clinical_management, evidence_level, source_url) VALUES (:drug_a_name, :drug_a_atc, :drug_b_name, :drug_b_atc, :severity, :description, :clinical_management, :evidence_level, :source_url)'); + $statement->execute([ + ':drug_a_name' => $data['drug_a_name'], + ':drug_a_atc' => $data['drug_a_atc'], + ':drug_b_name' => $data['drug_b_name'], + ':drug_b_atc' => $data['drug_b_atc'], + ':severity' => $data['severity'], + ':description' => $data['description'], + ':clinical_management' => $data['clinical_management'], + ':evidence_level' => $data['evidence_level'], + ':source_url' => $data['source_url'], + ]); + + return (int) $this->pdo->lastInsertId(); + } + + public function delete(int $id): void + { + $statement = $this->pdo->prepare('DELETE FROM interactions WHERE id = :id'); + $statement->execute([':id' => $id]); + } + + public function import(array $records): int + { + $count = 0; + $this->pdo->beginTransaction(); + try { + $statement = $this->pdo->prepare('INSERT INTO interactions (drug_a_name, drug_a_atc, drug_b_name, drug_b_atc, severity, description, clinical_management, evidence_level, source_url) VALUES (:drug_a_name, :drug_a_atc, :drug_b_name, :drug_b_atc, :severity, :description, :clinical_management, :evidence_level, :source_url) ON DUPLICATE KEY UPDATE drug_a_atc = VALUES(drug_a_atc), drug_b_atc = VALUES(drug_b_atc), severity = VALUES(severity), description = VALUES(description), clinical_management = VALUES(clinical_management), evidence_level = VALUES(evidence_level), source_url = VALUES(source_url)'); + + foreach ($records as $record) { + $statement->execute([ + ':drug_a_name' => $record['drug_a_name'], + ':drug_a_atc' => $record['drug_a_atc'], + ':drug_b_name' => $record['drug_b_name'], + ':drug_b_atc' => $record['drug_b_atc'], + ':severity' => $record['severity'], + ':description' => $record['description'], + ':clinical_management' => $record['clinical_management'], + ':evidence_level' => $record['evidence_level'], + ':source_url' => $record['source_url'], + ]); + $count++; + } + $this->pdo->commit(); + } catch (Throwable $exception) { + $this->pdo->rollBack(); + throw $exception; + } + + return $count; + } +} diff --git a/app/InteractionService.php b/app/InteractionService.php new file mode 100644 index 0000000..b19f924 --- /dev/null +++ b/app/InteractionService.php @@ -0,0 +1,152 @@ +repository->all($filters); + } + + public function exportCsv(array $filters = []): string + { + $interactions = $this->list($filters); + $handle = fopen('php://temp', 'r+'); + fputcsv($handle, ['drug_a_name', 'drug_a_atc', 'drug_b_name', 'drug_b_atc', 'severity', 'description', 'clinical_management', 'evidence_level', 'source_url']); + foreach ($interactions as $interaction) { + fputcsv($handle, [ + $interaction['drug_a_name'], + $interaction['drug_a_atc'], + $interaction['drug_b_name'], + $interaction['drug_b_atc'], + $interaction['severity'], + $interaction['description'], + $interaction['clinical_management'], + $interaction['evidence_level'], + $interaction['source_url'], + ]); + } + rewind($handle); + $csv = stream_get_contents($handle); + fclose($handle); + + return $csv; + } + + public function importCsv(string $path): int + { + $handle = fopen($path, 'r'); + if (!$handle) { + throw new RuntimeException('Unable to open import file.'); + } + + $header = fgetcsv($handle); + if (!$header) { + fclose($handle); + throw new RuntimeException('Import file is empty.'); + } + + $records = []; + while (($row = fgetcsv($handle)) !== false) { + if ($row === [null] || $row === false) { + continue; + } + + $record = array_combine($header, $row); + if ($record === false) { + continue; + } + $records[] = [ + 'drug_a_name' => $record['drug_a_name'] ?? '', + 'drug_a_atc' => $record['drug_a_atc'] ?? '', + 'drug_b_name' => $record['drug_b_name'] ?? '', + 'drug_b_atc' => $record['drug_b_atc'] ?? '', + 'severity' => $record['severity'] ?? 'moderate', + 'description' => $record['description'] ?? '', + 'clinical_management' => $record['clinical_management'] ?? '', + 'evidence_level' => $record['evidence_level'] ?? '', + 'source_url' => $record['source_url'] ?? '', + ]; + } + fclose($handle); + + return $this->repository->import($records); + } + + public function toFhirBundle(array $filters = []): array + { + $interactions = $this->list($filters); + $entries = []; + + foreach ($interactions as $interaction) { + $entries[] = [ + 'resource' => $this->toMedicationKnowledge($interaction), + 'fullUrl' => sprintf('urn:uuid:%s', $interaction['id'] ?? uniqid()), + ]; + } + + return [ + 'resourceType' => 'Bundle', + 'type' => 'collection', + 'timestamp' => gmdate('c'), + 'entry' => $entries, + ]; + } + + public function toMedicationKnowledge(array $interaction): array + { + return [ + 'resourceType' => 'MedicationKnowledge', + 'id' => (string) ($interaction['id'] ?? uniqid()), + 'status' => 'active', + 'code' => [ + 'coding' => [ + [ + 'system' => 'http://www.whocc.no/atc', + 'code' => $interaction['drug_a_atc'], + 'display' => $interaction['drug_a_name'], + ], + [ + 'system' => 'http://www.whocc.no/atc', + 'code' => $interaction['drug_b_atc'], + 'display' => $interaction['drug_b_name'], + ], + ], + 'text' => $interaction['drug_a_name'] . ' + ' . $interaction['drug_b_name'], + ], + 'contraindication' => [[ + 'reference' => 'DetectedIssue/' . ($interaction['id'] ?? uniqid()), + 'display' => $interaction['severity'] . ' interaction', + ]], + 'monitoringProgram' => [[ + 'name' => 'Clinical management', + 'type' => [ + 'text' => $interaction['clinical_management'], + ], + ]], + 'clinicalUseIssue' => [[ + 'classification' => [[ + 'text' => $interaction['severity'], + ]], + 'applicability' => [ + 'text' => $interaction['description'], + ], + ]], + 'relatedMedicationKnowledge' => [[ + 'type' => [ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/medicationknowledge-characteristic', + 'code' => 'interaction', + 'display' => 'Interaction', + ]], + ], + 'reference' => [[ + 'reference' => $interaction['source_url'], + 'display' => $interaction['evidence_level'], + ]], + ]], + ]; + } +} diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..171f647 --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,28 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} catch (PDOException $exception) { + http_response_code(500); + die('Database connection failed: ' . $exception->getMessage()); +} + +require_once __DIR__ . '/functions.php'; +require_once __DIR__ . '/Auth.php'; +require_once __DIR__ . '/InteractionRepository.php'; +require_once __DIR__ . '/InteractionService.php'; + +$translator = new Translator(__DIR__ . '/../resources/lang'); +$locale = determine_locale($translator); +$translator->setLocale($locale); diff --git a/app/functions.php b/app/functions.php new file mode 100644 index 0000000..f795034 --- /dev/null +++ b/app/functions.php @@ -0,0 +1,87 @@ +basePath = rtrim($basePath, '/'); + $this->loadLocale('en'); + } + + public function setLocale(string $locale): void + { + $this->loadLocale($locale); + $this->locale = $locale; + } + + public function getLocale(): string + { + return $this->locale; + } + + public function trans(string $key, array $replace = []): string + { + $message = $this->messages[$this->locale][$key] ?? $this->messages['en'][$key] ?? $key; + + foreach ($replace as $search => $value) { + $message = str_replace(':' . $search, (string) $value, $message); + } + + return $message; + } + + private function loadLocale(string $locale): void + { + if (!isset($this->messages[$locale])) { + $path = sprintf('%s/%s.php', $this->basePath, $locale); + $this->messages[$locale] = file_exists($path) ? require $path : []; + } + } +} + +function determine_locale(Translator $translator): string +{ + $supported = ['en', 'fr']; + if (!empty($_GET['lang']) && in_array($_GET['lang'], $supported, true)) { + $_SESSION['locale'] = $_GET['lang']; + } + + if (!empty($_SESSION['locale']) && in_array($_SESSION['locale'], $supported, true)) { + return $_SESSION['locale']; + } + + $header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''; + foreach (explode(',', $header) as $segment) { + $locale = substr($segment, 0, 2); + if (in_array($locale, $supported, true)) { + return $locale; + } + } + + return $translator->getLocale(); +} + +function e(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); +} + +function set_flash(string $type, string $message): void +{ + $_SESSION['flash'] = ['type' => $type, 'message' => $message]; +} + +function get_flash(): ?array +{ + if (!empty($_SESSION['flash'])) { + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + + return $flash; + } + + return null; +} diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..13bc38c --- /dev/null +++ b/config/database.php @@ -0,0 +1,9 @@ + getenv('DB_HOST') ?: 'localhost', + 'port' => getenv('DB_PORT') ?: '3306', + 'database' => getenv('DB_NAME') ?: 'openrims_interactions', + 'username' => getenv('DB_USER') ?: 'openrims', + 'password' => getenv('DB_PASSWORD') ?: 'secret', + 'charset' => 'utf8mb4', +]; diff --git a/database/sample_data.sql b/database/sample_data.sql new file mode 100644 index 0000000..4ff015c --- /dev/null +++ b/database/sample_data.sql @@ -0,0 +1,10 @@ +INSERT INTO interactions (drug_a_name, drug_a_atc, drug_b_name, drug_b_atc, severity, description, clinical_management, evidence_level, source_url) VALUES +('Warfarin', 'B01AA03', 'Trimethoprim', 'J01EA01', 'major', 'Trimethoprim potentiates the anticoagulant effect of warfarin leading to elevated INR.', 'Avoid combination when possible; increase INR monitoring if co-administration is required.', 'High', 'https://www.interaktionsdatabasen.dk'), +('Simvastatin', 'C10AA01', 'Clarithromycin', 'J01FA09', 'major', 'CYP3A4 inhibition by clarithromycin increases simvastatin plasma concentrations.', 'Hold simvastatin during macrolide treatment or switch to a non-interacting statin.', 'High', 'https://www.interaktionsdatabasen.dk'), +('Metformin', 'A10BA02', 'Iodinated contrast media', 'V08AB', 'moderate', 'Contrast-induced nephropathy may reduce metformin clearance increasing risk of lactic acidosis.', 'Withhold metformin on day of procedure and reassess renal function 48 hours post exposure.', 'Moderate', 'https://www.interaktionsdatabasen.dk'), +('Sertraline', 'N06AB06', 'Linezolid', 'J01XX08', 'major', 'Risk of serotonin syndrome due to MAO inhibition by linezolid.', 'Avoid combination; if unavoidable, monitor closely for serotonin toxicity.', 'Moderate', 'https://www.interaktionsdatabasen.dk'), +('Levothyroxine', 'H03AA01', 'Calcium carbonate', 'A12AA04', 'minor', 'Calcium may reduce levothyroxine absorption.', 'Separate administration by at least 4 hours and monitor TSH levels.', 'Low', 'https://www.interaktionsdatabasen.dk'); + +INSERT INTO users (name, email, password, role) VALUES +('System Administrator', 'admin@example.com', '$2y$12$6Bpe8ahWOKnGUERcnbjIB.C69TwWMypOj5.1j2GW6MvNQxkRVtxwe', 'admin'), +('Clinical Staff', 'staff@example.com', '$2y$12$N.mSuAtIFHybqh1baWceJ.jJ1U/V3Za9GrckUPzP3876itEpKIM5G', 'staff'); diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..6d71fcc --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS interactions ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + drug_a_name VARCHAR(255) NOT NULL, + drug_a_atc VARCHAR(20) NOT NULL, + drug_b_name VARCHAR(255) NOT NULL, + drug_b_atc VARCHAR(20) NOT NULL, + severity ENUM('minor', 'moderate', 'major') NOT NULL DEFAULT 'moderate', + description TEXT NOT NULL, + clinical_management TEXT NOT NULL, + evidence_level VARCHAR(100) DEFAULT NULL, + source_url VARCHAR(512) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_pair (drug_a_name, drug_b_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(191) NOT NULL, + email VARCHAR(191) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin', 'staff') NOT NULL DEFAULT 'staff', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/api/fhir/bundle.php b/public/api/fhir/bundle.php new file mode 100644 index 0000000..28ed41e --- /dev/null +++ b/public/api/fhir/bundle.php @@ -0,0 +1,17 @@ + $_GET['query'] ?? '', + 'severity' => $_GET['severity'] ?? '', +]; + +$bundle = $service->toFhirBundle($filters); + +header('Content-Type: application/fhir+json'); +echo json_encode($bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +exit; diff --git a/public/assets/custom.css b/public/assets/custom.css new file mode 100644 index 0000000..d4c769c --- /dev/null +++ b/public/assets/custom.css @@ -0,0 +1,126 @@ +body { + background-color: #f4f6f9; + font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +:root { + --pantone-294: #003A70; + --pantone-200: #BA0C2F; +} + +.main-header { + background-color: #ffffff !important; + border-bottom: 4px solid var(--pantone-294); + margin: 0.75rem 0.75rem 0; + border-radius: 0.85rem; +} + +.main-sidebar { + background: linear-gradient(180deg, var(--pantone-294) 0%, #012f57 100%); +} + +.brand-link { + border-bottom: none !important; +} + +.brand-text { + font-weight: 700; + letter-spacing: 0.02em; +} + +.sidebar-dark-primary .nav-sidebar > .nav-item > .nav-link.active, +.sidebar-dark-primary .nav-sidebar > .nav-item > .nav-link.active:hover { + background-color: var(--pantone-200); + color: #ffffff; + border-radius: 0.5rem; +} + +.sidebar-dark-primary .nav-sidebar > .nav-item > .nav-link { + color: rgba(255, 255, 255, 0.85); + border-radius: 0.5rem; +} + +.sidebar-dark-primary .nav-sidebar > .nav-item > .nav-link:hover { + background-color: rgba(255, 255, 255, 0.12); + color: #ffffff; +} + +.sidebar-dark-primary .nav-sidebar .nav-link i { + color: rgba(255, 255, 255, 0.8); +} + +.sidebar-dark-primary .nav-header { + color: rgba(255, 255, 255, 0.6); + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.btn-accent, +.btn-accent:hover, +.btn-accent:focus { + background-color: var(--pantone-200); + border-color: var(--pantone-200); + color: #ffffff; +} + +.card { + border-radius: 1rem; + border: 1px solid rgba(0,0,0,0.05); +} + +.table thead th { + border-top: none; + font-weight: 600; + color: #444444; +} + +.table tbody tr { + border-radius: 0.75rem; +} + +.badge-severity-major { + background-color: #b70000; +} + +.badge-severity-moderate { + background-color: #ffb703; + color: #222222; +} + +.badge-severity-minor { + background-color: #7cb342; +} + +.table-responsive { + border-radius: 0.75rem; +} + +.footer-dark { + background: #ffffff; + border-top: 4px solid var(--pantone-200); + border-radius: 0.75rem; + color: #555555; +} + +.welcome-banner { + background: linear-gradient(135deg, rgba(0, 58, 112, 0.95), rgba(186, 12, 47, 0.85)); + color: #ffffff; + border-radius: 1rem; +} + +.link-accent { + color: var(--pantone-200); +} + +.link-accent:hover { + color: #8a0923; +} + +.card-outline.card-primary { + border-top: 4px solid var(--pantone-200); +} + +.login-page .login-logo a { + color: var(--pantone-294); +} diff --git a/public/export.php b/public/export.php new file mode 100644 index 0000000..b3207f6 --- /dev/null +++ b/public/export.php @@ -0,0 +1,27 @@ +hasRole('staff', 'admin')) { + set_flash('danger', $translator->trans('auth_export_only')); + header('Location: index.php'); + exit; +} + +$repository = new InteractionRepository($pdo); +$service = new InteractionService($repository); + +$filters = [ + 'query' => $_GET['query'] ?? '', + 'severity' => $_GET['severity'] ?? '', +]; + +$csv = $service->exportCsv($filters); + +header('Content-Type: text/csv; charset=UTF-8'); +header('Content-Disposition: attachment; filename="interactions-' . gmdate('Ymd-His') . '.csv"'); +header('Content-Length: ' . strlen($csv)); + +echo $csv; +exit; diff --git a/public/import.php b/public/import.php new file mode 100644 index 0000000..8168a67 --- /dev/null +++ b/public/import.php @@ -0,0 +1,195 @@ +hasRole('admin')) { + set_flash('danger', $translator->trans('auth_import_only')); + header('Location: index.php'); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (empty($_FILES['file']['tmp_name'])) { + set_flash('danger', $translator->trans('import_error', ['message' => 'No file uploaded'])); + header('Location: import.php'); + exit; + } + + $repository = new InteractionRepository($pdo); + $service = new InteractionService($repository); + + try { + $count = $service->importCsv($_FILES['file']['tmp_name']); + set_flash('success', $translator->trans('import_success', ['count' => $count])); + } catch (Throwable $exception) { + set_flash('danger', $translator->trans('import_error', ['message' => $exception->getMessage()])); + } + + header('Location: import.php'); + exit; +} + +$user = $auth->user(); +$canExport = $auth->hasRole('staff', 'admin'); +$canImport = $auth->hasRole('admin'); +$roleLabel = null; +if ($user) { + $roleKey = 'role_' . $user['role']; + $roleLabel = $translator->trans($roleKey); +} +$flash = get_flash(); +$currentPage = 'import'; +?> + + + + + + <?= e($translator->trans('nav_import')); ?> · <?= e($translator->trans('app_title')); ?> + + + + + + +
+ + + + +
+
+
+
+
+
+
+

trans('import_heading')); ?>

+

trans('import_hint')); ?>

+
+
+
+
+ + +
+ + +
+ + +
+
+
+
+
+
+ +
+ + +
+
+

trans('import_instructions')); ?>

+
+ +
+
+
+
+
+
+
+ +
+ © OpenRIMS. trans('tagline')); ?> +
+
+ + + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..991d9d0 --- /dev/null +++ b/public/index.php @@ -0,0 +1,287 @@ +user(); +$canExport = $auth->hasRole('staff', 'admin'); +$canImport = $auth->hasRole('admin'); + +$repository = new InteractionRepository($pdo); +$service = new InteractionService($repository); + +$filters = [ + 'query' => $_GET['query'] ?? '', + 'severity' => $_GET['severity'] ?? '', +]; +$filtersQuery = http_build_query(array_filter($filters)); + +$interactions = $service->list($filters); +$severityOptions = [ + '' => $translator->trans('severity_all'), + 'major' => $translator->trans('severity_major'), + 'moderate' => $translator->trans('severity_moderate'), + 'minor' => $translator->trans('severity_minor'), +]; + +$flash = get_flash(); +$roleLabel = null; +if ($user) { + $roleKey = 'role_' . $user['role']; + $roleLabel = $translator->trans($roleKey); +} +$currentPage = 'index'; + +function severity_badge(string $severity): string +{ + $class = match ($severity) { + 'major' => 'badge-severity-major', + 'minor' => 'badge-severity-minor', + default => 'badge-severity-moderate', + }; + + return sprintf('%s', $class, ucfirst($severity)); +} +?> + + + + + + <?= e($translator->trans('app_title')); ?> + + + + + + +
+ + + + +
+
+
+
+
+
+
+
+

trans('app_title')); ?>

+

+ trans('tagline')); ?> +

+
+
+
trans('fhir_endpoint')); ?>
+ + trans('download_fhir')); ?> + +
+
+
+
+
+ + +
+
+
+
+

trans('nav_tools')); ?>

+ +
+
+
+
+ + + +
+ + +
+ + +
+
+
+
+ +
+ +
+
+
+ +
+ trans('reset')); ?> +
+ + + +
+
+ +
trans('no_results')); ?>
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
trans('drug_a')); ?>trans('drug_b')); ?>trans('severity')); ?>trans('clinical_management')); ?>trans('evidence_level')); ?>trans('source')); ?>
+
+ trans('atc_code')); ?>: +
+
+ trans('atc_code')); ?>: +
+
+ trans('description')); ?>: +
+ + + +
+
+ +
+
+
+
+
+ +
+ © OpenRIMS. trans('tagline')); ?> +
+
+ + + + + + diff --git a/public/login.php b/public/login.php new file mode 100644 index 0000000..60809a6 --- /dev/null +++ b/public/login.php @@ -0,0 +1,105 @@ +check()) { + header('Location: index.php'); + exit; +} + +$error = null; +$emailValue = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $email = trim($_POST['email'] ?? ''); + $emailValue = $email; + $password = $_POST['password'] ?? ''; + + if ($email === '' || $password === '') { + $error = $translator->trans('login_failed'); + } else { + if ($auth->attempt($email, $password)) { + $user = $auth->user(); + set_flash('success', $translator->trans('login_success', ['name' => $user['name']])); + header('Location: index.php'); + exit; + } + + $error = $translator->trans('login_failed'); + } +} + +$flash = get_flash(); +$locale = $translator->getLocale(); +?> + + + + + + <?= e($translator->trans('sign_in')); ?> · <?= e($translator->trans('app_title')); ?> + + + + + + +
+ +
+
+

trans('login_title')); ?>

+

trans('login_intro')); ?>

+
+
+ +
+ +
+ + +
+ +
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+

trans('nav_directory')); ?>

+
+ + + + + + diff --git a/public/logout.php b/public/logout.php new file mode 100644 index 0000000..fe51ec9 --- /dev/null +++ b/public/logout.php @@ -0,0 +1,13 @@ +check()) { + $auth->logout(); + session_regenerate_id(true); + set_flash('success', $translator->trans('logout_success')); +} + +header('Location: index.php'); +exit; diff --git a/resources/lang/en.php b/resources/lang/en.php new file mode 100644 index 0000000..313ce3e --- /dev/null +++ b/resources/lang/en.php @@ -0,0 +1,64 @@ + 'Drug Interaction Knowledge Base', + 'tagline' => 'Interoperable interaction data aligned with FHIR and ATC standards', + 'language' => 'Language', + 'english' => 'English', + 'french' => 'French', + 'nav_directory' => 'Interaction Directory', + 'nav_export' => 'Export Data', + 'nav_import' => 'Import Data', + 'nav_fhir' => 'FHIR Bundle', + 'nav_sign_in' => 'Sign in', + 'nav_sign_out' => 'Sign out', + 'nav_account' => 'Account', + 'nav_tools' => 'Data tools', + 'search_placeholder' => 'Search by medicine name or ATC code…', + 'severity' => 'Severity', + 'severity_all' => 'All severities', + 'severity_major' => 'Major', + 'severity_moderate' => 'Moderate', + 'severity_minor' => 'Minor', + 'actions' => 'Actions', + 'export' => 'Export', + 'import' => 'Import', + 'download_fhir' => 'FHIR Bundle', + 'import_instructions' => 'Import interactions via CSV with headers: drug_a_name, drug_a_atc, drug_b_name, drug_b_atc, severity, description, clinical_management, evidence_level, source_url.', + 'choose_file' => 'Choose file', + 'submit' => 'Submit', + 'cancel' => 'Cancel', + 'reset' => 'Reset', + 'no_results' => 'No interactions found for the selected criteria.', + 'clinical_management' => 'Clinical Management', + 'evidence_level' => 'Evidence Level', + 'description' => 'Description', + 'source' => 'Source', + 'atc_code' => 'ATC Code', + 'drug' => 'Medicine', + 'drug_a' => 'Medicine A', + 'drug_b' => 'Medicine B', + 'import_success' => ':count interactions imported successfully.', + 'import_error' => 'Import failed: :message', + 'upload_csv' => 'Upload CSV', + 'fhir_endpoint' => 'FHIR endpoint', + 'manage_data' => 'Manage data', + 'table_hint' => 'Use the search and filters to locate interaction profiles quickly.', + 'login_title' => 'Sign in to manage interactions', + 'login_intro' => 'Use your assigned credentials to access professional tools.', + 'email' => 'Email', + 'password' => 'Password', + 'remember_me' => 'Remember me', + 'sign_in' => 'Sign in', + 'auth_required' => 'Please sign in to continue.', + 'auth_export_only' => 'Only staff users may export data.', + 'auth_import_only' => 'Only administrators may import data.', + 'login_failed' => 'The provided credentials are invalid.', + 'login_success' => 'Welcome back, :name!', + 'logout_success' => 'You have been signed out securely.', + 'welcome_user' => 'Signed in as :name (:role).', + 'role_admin' => 'Administrator', + 'role_staff' => 'Staff', + 'import_hint' => 'Upload a UTF-8 CSV to add or update interaction records.', + 'import_heading' => 'Import interaction data', + 'import_history_note' => 'Imports will upsert interactions based on medicine pairs.', +]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php new file mode 100644 index 0000000..19daca6 --- /dev/null +++ b/resources/lang/fr.php @@ -0,0 +1,64 @@ + 'Base de connaissances des interactions médicamenteuses', + 'tagline' => 'Données d’interactions interopérables conformes aux standards FHIR et ATC', + 'language' => 'Langue', + 'english' => 'Anglais', + 'french' => 'Français', + 'nav_directory' => 'Répertoire des interactions', + 'nav_export' => 'Exporter les données', + 'nav_import' => 'Importer des données', + 'nav_fhir' => 'Bundle FHIR', + 'nav_sign_in' => 'Se connecter', + 'nav_sign_out' => 'Se déconnecter', + 'nav_account' => 'Compte', + 'nav_tools' => 'Outils de données', + 'search_placeholder' => 'Rechercher par nom de médicament ou code ATC…', + 'severity' => 'Gravité', + 'severity_all' => 'Toutes les gravités', + 'severity_major' => 'Élevée', + 'severity_moderate' => 'Modérée', + 'severity_minor' => 'Faible', + 'actions' => 'Actions', + 'export' => 'Exporter', + 'import' => 'Importer', + 'download_fhir' => 'Bundle FHIR', + 'import_instructions' => 'Importez les interactions via un CSV avec les en-têtes : drug_a_name, drug_a_atc, drug_b_name, drug_b_atc, severity, description, clinical_management, evidence_level, source_url.', + 'choose_file' => 'Choisir un fichier', + 'submit' => 'Envoyer', + 'cancel' => 'Annuler', + 'reset' => 'Réinitialiser', + 'no_results' => 'Aucune interaction trouvée pour les critères sélectionnés.', + 'clinical_management' => 'Prise en charge clinique', + 'evidence_level' => 'Niveau de preuve', + 'description' => 'Description', + 'source' => 'Source', + 'atc_code' => 'Code ATC', + 'drug' => 'Médicament', + 'drug_a' => 'Médicament A', + 'drug_b' => 'Médicament B', + 'import_success' => ':count interactions importées avec succès.', + 'import_error' => 'Échec de l’import : :message', + 'upload_csv' => 'Téléverser un CSV', + 'fhir_endpoint' => 'Point d’accès FHIR', + 'manage_data' => 'Gérer les données', + 'table_hint' => 'Utilisez la recherche et les filtres pour trouver rapidement des profils d’interaction.', + 'login_title' => 'Connectez-vous pour gérer les interactions', + 'login_intro' => 'Utilisez vos identifiants pour accéder aux outils professionnels.', + 'email' => 'Courriel', + 'password' => 'Mot de passe', + 'remember_me' => 'Se souvenir de moi', + 'sign_in' => 'Se connecter', + 'auth_required' => 'Veuillez vous connecter pour continuer.', + 'auth_export_only' => 'Seuls les membres du personnel peuvent exporter des données.', + 'auth_import_only' => 'Seuls les administrateurs peuvent importer des données.', + 'login_failed' => 'Les identifiants fournis sont invalides.', + 'login_success' => 'Bon retour, :name !', + 'logout_success' => 'Vous êtes maintenant déconnecté en toute sécurité.', + 'welcome_user' => 'Connecté en tant que :name (:role).', + 'role_admin' => 'Administrateur', + 'role_staff' => 'Personnel', + 'import_hint' => 'Téléversez un CSV UTF-8 pour ajouter ou mettre à jour des interactions.', + 'import_heading' => 'Importer des données d’interaction', + 'import_history_note' => 'Les importations mettent à jour les interactions selon les paires de médicaments.', +];