diff --git a/app/Commands/ProcessSnomedCoding.php b/app/Commands/ProcessSnomedCoding.php new file mode 100644 index 0000000..7eedd7f --- /dev/null +++ b/app/Commands/ProcessSnomedCoding.php @@ -0,0 +1,360 @@ +> /dev/null 2>&1 + * + * Picks pending rows from opd_coding_queue, extracts clinical phrases from + * free-text OPD fields, queries local SNOMED FULLTEXT index, saves + * suggestions into opd_snomed_suggestions for staff review in ABDM Coding Panel. + */ +class ProcessSnomedCoding extends BaseCommand +{ + protected $group = 'ABDM'; + protected $name = 'snomed:process-coding'; + protected $description = 'Process pending OPD SNOMED coding queue — extract phrases, match SNOMED codes, save suggestions.'; + + /** Max records per run (keep cron fast) */ + private const BATCH = 5; + + /** Source fields to process and their SNOMED semantic tag preferences */ + private const FIELD_TAGS = [ + 'complaints' => ['finding', 'disorder', 'symptom', 'observable entity'], + 'diagnosis' => ['disorder', 'finding', 'disease'], + 'Provisional_diagnosis' => ['disorder', 'finding', 'disease'], + 'Finding_Examinations' => ['finding', 'observable entity', 'procedure'], + ]; + + public function run(array $params): void + { + $db = \Config\Database::connect(); + + // Claim a batch atomically (status = pending → processing) + $rows = $db->table('opd_coding_queue') + ->where('status', 'pending') + ->orderBy('queued_at', 'ASC') + ->limit(self::BATCH) + ->get() + ->getResultArray(); + + if (empty($rows)) { + CLI::write('[snomed:process-coding] No pending items.', 'green'); + return; + } + + foreach ($rows as $qRow) { + $queueId = (int) $qRow['id']; + $opdId = (int) $qRow['opd_id']; + $sessionId = (int) $qRow['opd_session_id']; + + // Mark processing to prevent parallel workers grabbing same row + $db->table('opd_coding_queue') + ->where('id', $queueId) + ->where('status', 'pending') // optimistic lock + ->update(['status' => 'processing']); + + if ($db->affectedRows() === 0) { + // Another worker claimed it — skip + continue; + } + + CLI::write("[snomed:process-coding] Processing queue #{$queueId} opd_id={$opdId} session={$sessionId}"); + + try { + $prescription = $db->table('opd_prescription') + ->where('id', $sessionId) + ->where('opd_id', $opdId) + ->get(1) + ->getRowArray(); + + if (empty($prescription)) { + throw new \RuntimeException("opd_prescription row not found for session {$sessionId}"); + } + + $suggestionCount = $this->processPrescription($db, $opdId, $sessionId, $prescription); + + $db->table('opd_coding_queue') + ->where('id', $queueId) + ->update([ + 'status' => 'done', + 'has_suggestions' => $suggestionCount > 0 ? 1 : 0, + 'processed_at' => date('Y-m-d H:i:s'), + 'error_message' => null, + ]); + + CLI::write(" → {$suggestionCount} suggestions saved.", 'green'); + + } catch (\Throwable $e) { + $db->table('opd_coding_queue') + ->where('id', $queueId) + ->update([ + 'status' => 'failed', + 'processed_at' => date('Y-m-d H:i:s'), + 'error_message' => substr($e->getMessage(), 0, 500), + ]); + + CLI::write(" → FAILED: " . $e->getMessage(), 'red'); + } + } + + CLI::write('[snomed:process-coding] Done.', 'green'); + } + + // ------------------------------------------------------------------------- + + /** + * Process one OPD prescription row — extract phrases from all relevant + * fields and find SNOMED matches. + * + * @return int Number of suggestions inserted + */ + private function processPrescription(\CodeIgniter\Database\BaseConnection $db, int $opdId, int $sessionId, array $prescription): int + { + $inserted = 0; + + // Delete any stale suggestions for this session (re-process is safe) + $db->table('opd_snomed_suggestions') + ->where('opd_session_id', $sessionId) + ->where('status', 'pending_review') + ->delete(); + + foreach (self::FIELD_TAGS as $field => $preferredTags) { + $text = trim((string) ($prescription[$field] ?? '')); + if ($text === '') { + continue; + } + + $phrases = $this->extractPhrases($text); + + foreach ($phrases as $phrase) { + $matches = $this->searchSnomed($db, $phrase, $preferredTags, 3); + + foreach ($matches as $idx => $match) { + // Skip very low confidence + if ($match['confidence'] < 0.15) { + continue; + } + + $db->table('opd_snomed_suggestions')->insert([ + 'opd_id' => $opdId, + 'opd_session_id' => $sessionId, + 'source_field' => $field, + 'source_phrase' => substr($phrase, 0, 500), + 'concept_id' => $match['concept_id'], + 'snomed_term' => substr($match['term'], 0, 500), + 'semantic_tag' => substr($match['semantic_tag'], 0, 100), + 'confidence' => round($match['confidence'], 3), + 'status' => 'pending_review', + 'created_at' => date('Y-m-d H:i:s'), + ]); + $inserted++; + } + } + } + + return $inserted; + } + + /** + * Extract meaningful clinical phrases from free-text. + * Splits on delimiters, filters noise, deduplicates. + * + * @return string[] + */ + private function extractPhrases(string $text): array + { + // Strip NABH structured lines if present (Drug Allergy Status etc.) + $text = preg_replace('/^(Drug Allergy Status|Drug Allergy Details|ADR History|Current Medications)[^\n]*\n?/im', '', $text); + + // Normalise separators + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = preg_replace('/\s*[,;\/|]\s*/', "\n", $text); + $text = preg_replace('/\s+and\s+|\s+with\s+/i', "\n", $text); + + $lines = explode("\n", $text); + $phrases = []; + + foreach ($lines as $line) { + // Also split on sentence end inside a line + $parts = preg_split('/\.\s+/', $line); + foreach ($parts as $part) { + $part = trim(preg_replace('/\s+/', ' ', $part) ?? $part); + // Filter by length + if (mb_strlen($part) < 3 || mb_strlen($part) > 150) { + continue; + } + // Skip pure numeric or very short + if (preg_match('/^\d+$/', $part)) { + continue; + } + $key = mb_strtolower($part); + $phrases[$key] = $part; + } + } + + return array_values($phrases); + } + + /** + * Search local SNOMED FULLTEXT for a phrase. + * Returns top $limit results with confidence scores. + * + * @return array + */ + private function searchSnomed(\CodeIgniter\Database\BaseConnection $db, string $phrase, array $preferredTags, int $limit): array + { + if (trim($phrase) === '') { + return []; + } + + $safeTerm = str_replace(['"', "'", '\\'], ' ', $phrase); + $safeTerm = trim($safeTerm); + + if ($safeTerm === '') { + return []; + } + + try { + // Try FULLTEXT boolean mode first + $boolTerm = '+' . implode(' +', array_filter(explode(' ', $safeTerm))); + $sql = "SELECT + sd.conceptId AS concept_id, + sd.term, + MATCH(sd.term) AGAINST(? IN BOOLEAN MODE) AS ft_score + FROM snomed_description sd + WHERE sd.active = '1' + AND sd.typeId IN ('900000000000003001','900000000000013009') + AND MATCH(sd.term) AGAINST(? IN BOOLEAN MODE) + ORDER BY ft_score DESC + LIMIT ?"; + + $results = $db->query($sql, [$boolTerm, $boolTerm, $limit * 4])->getResultArray(); + + if (empty($results)) { + // Fallback to natural language mode + $sql2 = "SELECT + sd.conceptId AS concept_id, + sd.term, + MATCH(sd.term) AGAINST(? IN NATURAL LANGUAGE MODE) AS ft_score + FROM snomed_description sd + WHERE sd.active = '1' + AND sd.typeId IN ('900000000000003001','900000000000013009') + AND MATCH(sd.term) AGAINST(? IN NATURAL LANGUAGE MODE) + ORDER BY ft_score DESC + LIMIT ?"; + $results = $db->query($sql2, [$safeTerm, $safeTerm, $limit * 4])->getResultArray(); + } + + } catch (\Throwable $e) { + return []; + } + + if (empty($results)) { + return []; + } + + // Enrich with semantic tag and score + $conceptIds = array_unique(array_column($results, 'concept_id')); + $tagMap = $this->fetchSemanticTags($db, $conceptIds); + + $scored = []; + $maxFt = (float) max(array_column($results, 'ft_score')); + if ($maxFt <= 0) { + $maxFt = 1; + } + + foreach ($results as $row) { + $conceptId = (string) $row['concept_id']; + $term = (string) $row['term']; + $ftScore = (float) $row['ft_score']; + $semanticTag = $tagMap[$conceptId] ?? ''; + + // Normalised FT score + $ftNorm = $ftScore / $maxFt; + + // Term similarity bonus (word overlap) + $phraseWords = array_filter(explode(' ', mb_strtolower($phrase))); + $termWords = array_filter(explode(' ', mb_strtolower($term))); + $overlap = count(array_intersect($phraseWords, $termWords)); + $simBonus = $overlap / max(count($phraseWords), 1) * 0.3; + + // Preferred semantic tag bonus + $tagBonus = 0.0; + $tagLower = mb_strtolower($semanticTag); + foreach ($preferredTags as $preferred) { + if (str_contains($tagLower, mb_strtolower($preferred))) { + $tagBonus = 0.2; + break; + } + } + + $confidence = min(1.0, $ftNorm * 0.5 + $simBonus + $tagBonus); + + $scored[] = [ + 'concept_id' => $conceptId, + 'term' => $term, + 'semantic_tag' => $semanticTag, + 'confidence' => $confidence, + ]; + } + + // De-duplicate by concept_id, keeping highest confidence + $best = []; + foreach ($scored as $item) { + $cid = $item['concept_id']; + if (!isset($best[$cid]) || $item['confidence'] > $best[$cid]['confidence']) { + $best[$cid] = $item; + } + } + + // Sort descending + usort($best, fn($a, $b) => $b['confidence'] <=> $a['confidence']); + + return array_slice(array_values($best), 0, $limit); + } + + /** + * Fetch semantic tags for an array of conceptIds. + * Parses parenthesised tag from FSN term e.g. "Fever (finding)" → "finding" + * + * @param string[] $conceptIds + * @return array + */ + private function fetchSemanticTags(\CodeIgniter\Database\BaseConnection $db, array $conceptIds): array + { + if (empty($conceptIds)) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($conceptIds), '?')); + + try { + $rows = $db->query( + "SELECT conceptId, term FROM snomed_description + WHERE conceptId IN ({$placeholders}) + AND typeId = '900000000000003001' + AND active = '1' + LIMIT " . (count($conceptIds) * 2), + $conceptIds + )->getResultArray(); + } catch (\Throwable $e) { + return []; + } + + $map = []; + foreach ($rows as $row) { + if (preg_match('/\(([^)]+)\)\s*$/', (string) $row['term'], $m)) { + $map[(string) $row['conceptId']] = $m[1]; + } + } + + return $map; + } +} diff --git a/app/Config/AbdmConnector.php b/app/Config/AbdmConnector.php index e341a14..8285187 100644 --- a/app/Config/AbdmConnector.php +++ b/app/Config/AbdmConnector.php @@ -136,4 +136,12 @@ class AbdmConnector extends BaseConfig /** HTTP timeout in seconds for direct ABDM API calls. */ public int $directAbdmTimeoutSec = 30; + + // ------------------------------------------------------------------ + // FHIR record encryption key (AES-256-GCM) + // Set in .env: ABDM_FHIR_ENCRYPTION_KEY = <64-char hex string> + // Generate: php -r "echo bin2hex(random_bytes(32));" + // REQUIRED on production; falls back to a derived key in development. + // ------------------------------------------------------------------ + // ABDM_FHIR_ENCRYPTION_KEY = } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index e18f671..f0199d8 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -542,9 +542,21 @@ $routes->post('AbdmGateway/abha_validate', 'AbdmGateway::abhaValidate', ['filter $routes->post('AbdmGateway/scan_share_lookup', 'AbdmGateway::scanShareLookup', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback'); +$routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevokedCallback'); +$routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback'); $routes->post('AbdmGateway/share_prescription_bundle', 'AbdmGateway::sharePrescriptionBundle', ['filter' => $abdmPermFilter]); +// SNOMED Coding Panel +$routes->get('AbdmCodingPanel', 'AbdmCodingPanel::index', ['filter' => $abdmPermFilter]); +$routes->get('AbdmCodingPanel/review/(:num)', 'AbdmCodingPanel::review/$1', ['filter' => $abdmPermFilter]); +$routes->post('AbdmCodingPanel/confirm', 'AbdmCodingPanel::confirm', ['filter' => $abdmPermFilter]); +$routes->post('AbdmCodingPanel/reject', 'AbdmCodingPanel::reject', ['filter' => $abdmPermFilter]); +$routes->post('AbdmCodingPanel/correct', 'AbdmCodingPanel::correct', ['filter' => $abdmPermFilter]); +$routes->post('AbdmCodingPanel/mark_fhir_ready', 'AbdmCodingPanel::markFhirReady', ['filter' => $abdmPermFilter]); +$routes->get('AbdmCodingPanel/tip_check', 'AbdmCodingPanel::tipCheck'); $routes->post('AbdmGateway/share_ipd_discharge_bundle', 'AbdmGateway::shareIpdDischargeBundle', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/share_diagnosis_report_bundle', 'AbdmGateway::shareDiagnosisReportBundle', ['filter' => $abdmPermFilter]); +$routes->post('AbdmGateway/push_health_record', 'AbdmGateway::pushHealthRecord', ['filter' => $abdmPermFilter]); +$routes->get('AbdmGateway/health_records_list', 'AbdmGateway::healthRecordsList', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/nhcx_claim_create', 'AbdmGateway::nhcxClaimCreate', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/nhcx_claim_status_request', 'AbdmGateway::nhcxClaimStatusRequest', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/nhcx_claim_status_callback', 'AbdmGateway::nhcxClaimStatusCallback'); @@ -576,8 +588,12 @@ $routes->get('Opd_prescription/fhir_bundle/(:num)', 'Opd_prescription::fhir_bund $routes->get('Opd_prescription/fhir_bundle/(:num)/(:num)', 'Opd_prescription::fhir_bundle/$1/$2'); $routes->get('Opd_prescription/fhir_bundle_history/(:num)', 'Opd_prescription::fhir_bundle_history/$1'); $routes->get('Opd_prescription/fhir_bundle_history/(:num)/(:num)', 'Opd_prescription::fhir_bundle_history/$1/$2'); +$routes->get('Opd_prescription/fhir_bundle_preview/(:num)', 'Opd_prescription::fhir_bundle_preview/$1'); +$routes->get('Opd_prescription/fhir_bundle_preview/(:num)/(:num)', 'Opd_prescription::fhir_bundle_preview/$1/$2'); $routes->get('Opd_prescription/complaints_search', 'Opd_prescription::complaints_search'); +$routes->get('Opd_prescription/section_recent_entries', 'Opd_prescription::section_recent_entries'); $routes->get('Opd_prescription/provisional_diagnosis_search', 'Opd_prescription::provisional_diagnosis_search'); +$routes->get('Opd_prescription/diagnosis_search', 'Opd_prescription::provisional_diagnosis_search'); $routes->get('Opd_prescription/finding_exam_search', 'Opd_prescription::finding_exam_search'); $routes->get('Opd_prescription/get_complaints', 'Opd_prescription::get_complaints'); $routes->get('Opd_prescription/get_disease', 'Opd_prescription::get_disease'); @@ -680,6 +696,7 @@ $routes->post('Opd_prescription/investigation_remove/(:num)', 'Opd_prescription: $routes->get('Opd_prescription/opd_invest_master', 'Opd_prescription::opd_invest_master', ['filter' => 'permission:doctor_work.medicine.manage,doctor_work.access']); $routes->get('Opd_prescription/opd_invest_master_specs', 'Opd_prescription::opd_invest_master_specs'); $routes->get('Opd_prescription/opd_invest_master_data', 'Opd_prescription::opd_invest_master_data'); +$routes->get('Opd_prescription/opd_invest_snomed_search', 'Opd_prescription::opd_invest_snomed_search'); $routes->get('Opd_prescription/opd_invest_master_get/(:num)', 'Opd_prescription::opd_invest_master_get/$1'); $routes->post('Opd_prescription/opd_invest_master_save', 'Opd_prescription::opd_invest_master_save'); $routes->post('Opd_prescription/opd_invest_master_remove/(:num)', 'Opd_prescription::opd_invest_master_remove/$1'); diff --git a/app/Controllers/AbdmCodingPanel.php b/app/Controllers/AbdmCodingPanel.php new file mode 100644 index 0000000..422ac99 --- /dev/null +++ b/app/Controllers/AbdmCodingPanel.php @@ -0,0 +1,314 @@ +query(" + SELECT + q.id AS queue_id, + q.opd_id, + q.opd_session_id, + q.status AS queue_status, + q.queued_at, + q.processed_at, + om.opd_date, + om.opd_no, + pm.p_name AS patient_name, + pm.p_mobile AS patient_mobile, + (SELECT COUNT(*) FROM opd_snomed_suggestions s + WHERE s.opd_session_id = q.opd_session_id) AS total_suggestions, + (SELECT COUNT(*) FROM opd_snomed_suggestions s + WHERE s.opd_session_id = q.opd_session_id + AND s.status = 'pending_review') AS pending_count, + (SELECT COUNT(*) FROM opd_snomed_suggestions s + WHERE s.opd_session_id = q.opd_session_id + AND s.status = 'confirmed') AS confirmed_count + FROM opd_coding_queue q + LEFT JOIN opd_master om ON om.opd_id = q.opd_id + LEFT JOIN patient_master pm ON pm.id = om.p_id + WHERE q.status IN ('done','failed') + AND q.has_suggestions = 1 + ORDER BY q.queued_at DESC + LIMIT 200 + ")->getResultArray(); + + return view('abdm/coding_panel', [ + 'sessions' => $rows, + 'page_title' => 'SNOMED Coding Panel', + ]); + } + + // ------------------------------------------------------------------------- + // Review — show all suggestions for one OPD session + // ------------------------------------------------------------------------- + + public function review(int $sessionId = 0) + { + if ($sessionId <= 0) { + return redirect()->to('AbdmCodingPanel'); + } + + $db = \Config\Database::connect(); + + $suggestions = $db->table('opd_snomed_suggestions') + ->where('opd_session_id', $sessionId) + ->orderBy('source_field', 'ASC') + ->orderBy('confidence', 'DESC') + ->get() + ->getResultArray(); + + if (empty($suggestions)) { + return redirect()->to('AbdmCodingPanel')->with('message', 'No suggestions found for this session.'); + } + + // Fetch consult header info + $consult = $db->query(" + SELECT op.*, om.opd_date, om.opd_no, pm.p_name AS patient_name + FROM opd_prescription op + JOIN opd_master om ON om.opd_id = op.opd_id + JOIN patient_master pm ON pm.id = om.p_id + WHERE op.id = ? + LIMIT 1 + ", [$sessionId])->getRowArray(); + + $queueRow = $db->table('opd_coding_queue') + ->where('opd_session_id', $sessionId) + ->get(1) + ->getRowArray(); + + return view('abdm/coding_panel_review', [ + 'session_id' => $sessionId, + 'consult' => $consult, + 'suggestions' => $suggestions, + 'queue_row' => $queueRow, + 'page_title' => 'Review SNOMED Codes', + ]); + } + + // ------------------------------------------------------------------------- + // Confirm a suggestion + // ------------------------------------------------------------------------- + + public function confirm() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0]); + } + + $id = (int) $this->request->getPost('suggestion_id'); + $userId = (int) session()->get('user_id'); + + if ($id <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid suggestion_id']); + } + + $db = \Config\Database::connect(); + $db->table('opd_snomed_suggestions') + ->where('id', $id) + ->update([ + 'status' => 'confirmed', + 'reviewed_by' => $userId ?: null, + 'reviewed_at' => date('Y-m-d H:i:s'), + ]); + + return $this->response->setJSON(['ok' => 1, 'suggestion_id' => $id, 'status' => 'confirmed']); + } + + // ------------------------------------------------------------------------- + // Reject a suggestion + // ------------------------------------------------------------------------- + + public function reject() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0]); + } + + $id = (int) $this->request->getPost('suggestion_id'); + $userId = (int) session()->get('user_id'); + + if ($id <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid suggestion_id']); + } + + $db = \Config\Database::connect(); + $db->table('opd_snomed_suggestions') + ->where('id', $id) + ->update([ + 'status' => 'rejected', + 'reviewed_by' => $userId ?: null, + 'reviewed_at' => date('Y-m-d H:i:s'), + ]); + + return $this->response->setJSON(['ok' => 1, 'suggestion_id' => $id, 'status' => 'rejected']); + } + + // ------------------------------------------------------------------------- + // Correct — reviewer overrides with a different SNOMED code + // ------------------------------------------------------------------------- + + public function correct() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0]); + } + + $id = (int) $this->request->getPost('suggestion_id'); + $conceptId = trim((string) $this->request->getPost('corrected_concept_id')); + $term = trim((string) $this->request->getPost('corrected_term')); + $userId = (int) session()->get('user_id'); + + if ($id <= 0 || $conceptId === '' || $term === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'suggestion_id, corrected_concept_id and corrected_term are required']); + } + + // Validate concept_id is numeric (18-digit SNOMED CT) + if (! preg_match('/^\d{6,18}$/', $conceptId)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid SNOMED concept_id format']); + } + + $db = \Config\Database::connect(); + $db->table('opd_snomed_suggestions') + ->where('id', $id) + ->update([ + 'status' => 'corrected', + 'corrected_concept_id' => $conceptId, + 'corrected_term' => substr($term, 0, 500), + 'reviewed_by' => $userId ?: null, + 'reviewed_at' => date('Y-m-d H:i:s'), + ]); + + return $this->response->setJSON(['ok' => 1, 'suggestion_id' => $id, 'status' => 'corrected']); + } + + // ------------------------------------------------------------------------- + // Mark all confirmed/corrected suggestions for this session as FHIR-ready + // (updates opd_prescription SNOMED fields with the final codes) + // ------------------------------------------------------------------------- + + public function markFhirReady() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0]); + } + + $sessionId = (int) $this->request->getPost('session_id'); + if ($sessionId <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'session_id required']); + } + + $db = \Config\Database::connect(); + + // Load finalised suggestions for this session + $suggestions = $db->table('opd_snomed_suggestions') + ->where('opd_session_id', $sessionId) + ->whereIn('status', ['confirmed', 'corrected']) + ->get() + ->getResultArray(); + + if (empty($suggestions)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'No confirmed/corrected suggestions found']); + } + + // Build field → best-confidence suggestion map + $byField = []; + foreach ($suggestions as $s) { + $field = $s['source_field']; + if (!isset($byField[$field]) || (float) $s['confidence'] > (float) $byField[$field]['confidence']) { + $byField[$field] = $s; + } + } + + // Map our source_field names to opd_prescription column names + $fieldColumnMap = [ + 'complaints' => ['snomed_id' => null, 'snomed_term' => null], // stored in complaint_snomed_json + 'diagnosis' => ['snomed_id' => 'diagnosis_snomed_id', 'snomed_term' => 'diagnosis_snomed_term'], + 'Provisional_diagnosis' => ['snomed_id' => 'provisional_diagnosis_snomed_id', 'snomed_term' => 'provisional_diagnosis_snomed_term'], + ]; + + $updates = []; + foreach ($byField as $field => $s) { + $finalConceptId = $s['status'] === 'corrected' ? $s['corrected_concept_id'] : $s['concept_id']; + $finalTerm = $s['status'] === 'corrected' ? $s['corrected_term'] : $s['snomed_term']; + + if (! isset($fieldColumnMap[$field])) { + continue; + } + $cols = $fieldColumnMap[$field]; + if ($cols['snomed_id'] !== null) { + $updates[$cols['snomed_id']] = $finalConceptId; + $updates[$cols['snomed_term']] = $finalTerm; + $updates[str_replace('_id', '_source', $cols['snomed_id'])] = 'coding-panel'; + } + } + + if (! empty($updates)) { + $db->table('opd_prescription') + ->where('id', $sessionId) + ->update($updates); + } + + // Mark queue row as fhir-ready (reuse 'done' status, set has_suggestions = 2 as flag) + $db->table('opd_coding_queue') + ->where('opd_session_id', $sessionId) + ->update(['has_suggestions' => 2]); + + return $this->response->setJSON([ + 'ok' => 1, + 'session_id' => $sessionId, + 'updated' => count($updates) / 2, + ]); + } + + // ------------------------------------------------------------------------- + // Tip check — lightweight poll from the consult form after save + // Returns whether fast coding suggestions are available for this session + // ------------------------------------------------------------------------- + + public function tipCheck() + { + $sessionId = (int) $this->request->getGet('session_id'); + if ($sessionId <= 0) { + return $this->response->setJSON(['has_suggestions' => 0]); + } + + $db = \Config\Database::connect(); + + $count = $db->table('opd_snomed_suggestions') + ->where('opd_session_id', $sessionId) + ->where('status', 'pending_review') + ->countAllResults(); + + return $this->response->setJSON([ + 'has_suggestions' => $count > 0 ? 1 : 0, + 'count' => $count, + 'review_url' => base_url('AbdmCodingPanel/review/' . $sessionId), + ]); + } +} diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index a5018f3..4a6fa97 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -5,6 +5,8 @@ namespace App\Controllers; use App\Libraries\Abdm\AbdmConnectorInterface; use App\Libraries\Abdm\AbdmConnectorFactory; use App\Libraries\FhirR4Builder; +use App\Libraries\FhirEncryptionService; +use App\Libraries\AbdmAuditService; use CodeIgniter\I18n\Time; class AbdmGateway extends BaseController @@ -328,13 +330,43 @@ class AbdmGateway extends BaseController 'bundle' => $bundle, ]; + // Store FHIR payload in health_records before pushing + $healthRecordId = $this->storeHealthRecord([ + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'hi_type' => 'OPConsultRecord', + 'entity_type' => 'opd', + 'entity_id' => (string) $opdId, + 'fhir_bundle' => $bundleJson, + 'consent_handle' => (string) ($consent['consent_handle'] ?? ''), + ]); + $queueId = null; + $connectorError = null; try { $result = $this->connector->sharePrescriptionBundle($payload, (string) ($bundleRow['id'] ?? '')); $queueId = $result['queue_id'] ?? null; } catch (\Throwable $e) { + $connectorError = $e->getMessage(); } + // Update health_record with txn_id and create record_link + if ($healthRecordId > 0) { + $this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError); + } + + $this->getAuditService()->log([ + 'action' => 'push_record', + 'entity_type' => 'opd', + 'entity_id' => (string) $opdId, + 'abha_id' => $abhaId, + 'patient_id' => $patientId, + 'request' => ['opd_id' => $opdId, 'hi_type' => 'OPConsultRecord', 'consent_handle' => (string) ($consent['consent_handle'] ?? '')], + 'response' => ['queue_id' => $queueId], + 'outcome' => $connectorError === null ? 'success' : 'failure', + 'error_message' => (string) ($connectorError ?? ''), + ]); + return $this->response->setJSON([ 'ok' => 1, 'queue_id' => $queueId, @@ -393,13 +425,42 @@ class AbdmGateway extends BaseController 'summary_html' => $summaryHtml, ]; + // Store in health_records + $healthRecordId = $this->storeHealthRecord([ + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'hi_type' => 'DischargeSummary', + 'entity_type' => 'ipd', + 'entity_id' => (string) $ipdId, + 'fhir_bundle' => json_encode($payload), + 'consent_handle' => (string) ($consent['consent_handle'] ?? ''), + ]); + $queueId = null; + $connectorError = null; try { $result = $this->connector->shareIpdDischargeBundle($payload, (string) $ipdId); $queueId = $result['queue_id'] ?? null; } catch (\Throwable $e) { + $connectorError = $e->getMessage(); } + if ($healthRecordId > 0) { + $this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError); + } + + $this->getAuditService()->log([ + 'action' => 'push_record', + 'entity_type' => 'ipd', + 'entity_id' => (string) $ipdId, + 'abha_id' => $abhaId, + 'patient_id' => $patientId, + 'request' => ['ipd_id' => $ipdId, 'hi_type' => 'DischargeSummary', 'consent_handle' => (string) ($consent['consent_handle'] ?? '')], + 'response' => ['queue_id' => $queueId], + 'outcome' => $connectorError === null ? 'success' : 'failure', + 'error_message' => (string) ($connectorError ?? ''), + ]); + return $this->response->setJSON([ 'ok' => 1, 'queue_id' => $queueId, @@ -451,13 +512,42 @@ class AbdmGateway extends BaseController 'consent_handle' => $consentRecord['consent_handle'] ?? $consentHandle, ]; + // Store in health_records (report HTML as the FHIR payload placeholder) + $healthRecordId = $this->storeHealthRecord([ + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'hi_type' => 'DiagnosticReport', + 'entity_type' => 'lab', + 'entity_id' => (string) $labReqId, + 'fhir_bundle' => json_encode($payload), + 'consent_handle' => $payload['consent_handle'], + ]); + $queueId = null; + $connectorError = null; try { $result = $this->connector->shareDiagnosisReportBundle($payload, (string) $labReqId); $queueId = $result['queue_id'] ?? null; } catch (\Throwable $e) { + $connectorError = $e->getMessage(); } + if ($healthRecordId > 0) { + $this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError); + } + + $this->getAuditService()->log([ + 'action' => 'push_record', + 'entity_type' => 'lab', + 'entity_id' => (string) $labReqId, + 'abha_id' => $abhaId, + 'patient_id' => $patientId, + 'request' => ['lab_req_id' => $labReqId, 'hi_type' => 'DiagnosticReport', 'consent_handle' => $payload['consent_handle']], + 'response' => ['queue_id' => $queueId], + 'outcome' => $connectorError === null ? 'success' : 'failure', + 'error_message' => (string) ($connectorError ?? ''), + ]); + return $this->response->setJSON([ 'ok' => 1, 'queue_id' => $queueId, @@ -684,6 +774,325 @@ class AbdmGateway extends BaseController ]); } + // ========================================================================= + // Record Linked Callback — ABDM notifies HMS when a record is successfully + // linked to an ABHA address. + // POST /AbdmGateway/record_linked_callback (no auth filter — public webhook) + // ========================================================================= + + public function recordLinkedCallback() + { + $signatureFailure = $this->validateWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload)) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON payload']); + } + + $abdmTxnId = trim((string) ($payload['queue_id'] ?? $payload['abdm_txn_id'] ?? '')); + $careContextRef = trim((string) ($payload['care_context_reference'] ?? $payload['care_context_ref'] ?? '')); + $abhaId = trim((string) ($payload['abha_id'] ?? '')); + $status = strtolower(trim((string) ($payload['status'] ?? 'linked'))); + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + + if ($abdmTxnId === '' && $careContextRef === '') { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'queue_id or care_context_reference is required']); + } + + // Update health_records + if ($this->db->tableExists('health_records')) { + $hrBuilder = $this->db->table('health_records'); + if ($abdmTxnId !== '') { + $hrBuilder->where('abdm_txn_id', $abdmTxnId); + } elseif ($careContextRef !== '') { + $hrBuilder->where('care_context_reference', $careContextRef); + } + $hrBuilder->update([ + 'push_status' => 'linked', + 'linked_at' => $now, + 'care_context_reference' => $careContextRef !== '' ? $careContextRef : null, + 'updated_at' => $now, + ]); + } + + // Insert / update record_links + if ($this->db->tableExists('record_links')) { + $existing = $this->db->table('record_links') + ->where('abdm_txn_id', $abdmTxnId) + ->get(1) + ->getRowArray(); + + $responseJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if (! empty($existing)) { + $this->db->table('record_links') + ->where('id', (int) $existing['id']) + ->update([ + 'link_status' => $status, + 'care_context_reference' => $careContextRef !== '' ? $careContextRef : (string) ($existing['care_context_reference'] ?? ''), + 'response_json' => $responseJson, + 'linked_at' => $now, + 'updated_at' => $now, + ]); + } else { + $this->db->table('record_links')->insert([ + 'abdm_txn_id' => $abdmTxnId !== '' ? $abdmTxnId : null, + 'care_context_reference' => $careContextRef !== '' ? $careContextRef : null, + 'abha_id' => $abhaId !== '' ? $abhaId : null, + 'link_status' => $status, + 'response_json' => $responseJson, + 'linked_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + $this->getAuditService()->log([ + 'action' => 'record_linked', + 'entity_type' => 'health_record', + 'entity_id' => $abdmTxnId, + 'abha_id' => $abhaId, + 'response' => $payload, + 'outcome' => $status === 'linked' ? 'success' : 'failure', + ]); + + // Update patient_master.abdm_linked_at when a record is confirmed linked + if ($status === 'linked' && $abhaId !== '' && $this->db->tableExists('patient_master')) { + $pmFields = $this->db->getFieldNames('patient_master') ?? []; + if (in_array('abdm_linked_at', $pmFields, true)) { + $abhaFieldCandidates = ['abha_id', 'abha_no', 'abha_address', 'abha']; + foreach ($abhaFieldCandidates as $col) { + if (in_array($col, $pmFields, true)) { + $this->db->table('patient_master') + ->where($col, $abhaId) + ->where('abdm_linked_at IS NULL', null, false) + ->update(['abdm_linked_at' => $now]); + break; + } + } + } + } + + return $this->response->setJSON(['ok' => 1, 'status' => $status]); + } + + // ========================================================================= + // Consent Revoked Callback — ABDM notifies HMS when consent is revoked. + // POST /AbdmGateway/consent_revoked_callback (no auth filter — public webhook) + // ========================================================================= + + public function consentRevokedCallback() + { + $signatureFailure = $this->validateWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload)) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON payload']); + } + + $consentHandle = trim((string) ($payload['consent_handle'] ?? '')); + $abhaId = trim((string) ($payload['abha_id'] ?? '')); + $revokedAt = trim((string) ($payload['revoked_at'] ?? '')); + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + + if ($consentHandle === '') { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'consent_handle is required']); + } + + if (! $this->db->tableExists('abdm_consent_records')) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'abdm_consent_records table not found']); + } + + $existing = $this->db->table('abdm_consent_records') + ->where('consent_handle', $consentHandle) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + if (empty($existing)) { + return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'consent_handle not found']); + } + + // Idempotency — already revoked + if (strtolower((string) ($existing['consent_status'] ?? '')) === 'revoked') { + return $this->response->setJSON(['ok' => 1, 'consent_handle' => $consentHandle, 'status' => 'revoked', 'duplicate' => 1]); + } + + $this->db->table('abdm_consent_records') + ->where('consent_handle', $consentHandle) + ->update([ + 'consent_status' => 'revoked', + 'raw_payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => $now, + ]); + + $this->getAuditService()->log([ + 'action' => 'consent_revoke', + 'entity_type' => 'consent', + 'entity_id' => $consentHandle, + 'abha_id' => $abhaId, + 'patient_id' => (int) ($existing['patient_id'] ?? 0), + 'response' => $payload, + 'outcome' => 'success', + ]); + + return $this->response->setJSON(['ok' => 1, 'consent_handle' => $consentHandle, 'status' => 'revoked']); + } + + // ========================================================================= + // Push a specific health_record to ABDM (user-triggered retry / initial push) + // POST /AbdmGateway/push_health_record + // ========================================================================= + + public function pushHealthRecord() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $hrId = (int) $this->request->getPost('health_record_id'); + $consentHandle = trim((string) $this->request->getPost('consent_handle')); + + if ($hrId <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'health_record_id is required']); + } + + if (! $this->db->tableExists('health_records')) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'health_records table not found']); + } + + $hr = $this->db->table('health_records') + ->where('id', $hrId) + ->get(1) + ->getRowArray(); + + if (empty($hr)) { + return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Health record not found']); + } + + $patientId = (int) ($hr['patient_id'] ?? 0); + $abhaId = trim((string) ($hr['abha_id'] ?? '')); + $hiType = trim((string) ($hr['hi_type'] ?? '')); + + if ($abhaId === '') { + // Try from POST param as override + $abhaId = trim((string) $this->request->getPost('abha_id')); + } + + // Decrypt the stored payload (JSON encoded full push payload, may contain 'bundle' key) + $storedPayload = []; + $encPayload = trim((string) ($hr['fhir_bundle_enc'] ?? '')); + if ($encPayload !== '') { + try { + $enc = new \App\Libraries\FhirEncryptionService(); + $decrypted = $enc->decrypt($encPayload); + $storedPayload = json_decode($decrypted, true) ?? []; + } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => 'Could not decrypt FHIR bundle: ' . $e->getMessage()]); + } + } + + // Extract just the FHIR bundle from the stored payload (stored as full HMS payload) + $fhirBundle = $storedPayload['bundle'] ?? $storedPayload['fhir_bundle'] ?? $storedPayload; + + $entityType = trim((string) ($hr['entity_type'] ?? '')); + $entityId = trim((string) ($hr['entity_id'] ?? '')); + $visitDate = trim((string) ($storedPayload['visit_date'] ?? $storedPayload['reg_date'] ?? $storedPayload['reported_time'] ?? '')); + $doctorName = trim((string) ($storedPayload['doctor_name'] ?? $storedPayload['doctor'] ?? '')); + $patientName = trim((string) ($storedPayload['patient_name'] ?? '')); + $department = trim((string) ($storedPayload['department'] ?? $storedPayload['dept_name'] ?? '')); + + $queueId = null; + $connectorErr = null; + try { + // Use the new store-and-link flow (POST /v3/records/push) for re-push: + // No consent needed — bridge stores record and serves it when ABDM requests data. + $result = $this->connector->pushRecord([ + 'patient_id' => (string) $patientId, + 'patient_name' => $patientName, + 'abha_id' => $abhaId, + 'hi_type' => $hiType, + 'visit_date' => $visitDate !== '' ? $visitDate : date('Y-m-d'), + 'doctor_name' => $doctorName, + 'department' => $department, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'care_context_reference' => trim((string) ($hr['care_context_reference'] ?? '')), + 'record_data' => $fhirBundle, + ]); + $queueId = $result['queue_id'] ?? null; + } catch (\Throwable $e) { + $connectorErr = $e->getMessage(); + } + + $this->updateHealthRecordTxn($hrId, (string) ($queueId ?? ''), $connectorErr); + + $this->getAuditService()->log([ + 'action' => 'push_record', + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'abha_id' => $abhaId, + 'patient_id' => $patientId, + 'request' => ['health_record_id' => $hrId, 'hi_type' => $hiType], + 'response' => ['queue_id' => $queueId], + 'outcome' => $connectorErr === null ? 'success' : 'failure', + 'error_message' => (string) ($connectorErr ?? ''), + ]); + + return $this->response->setJSON([ + 'ok' => $connectorErr === null ? 1 : 0, + 'queue_id' => $queueId, + 'error' => $connectorErr, + 'status' => $connectorErr === null ? 'queued' : 'failed', + ]); + } + + // ========================================================================= + // List health_records for a patient (AJAX — used by ABDM task board) + // GET /AbdmGateway/health_records_list?patient_id=X&abha_id=Y + // ========================================================================= + + public function healthRecordsList() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + if (! $this->db->tableExists('health_records')) { + return $this->response->setJSON(['ok' => 1, 'records' => [], 'total' => 0]); + } + + $patientId = (int) $this->request->getGet('patient_id'); + $abhaId = trim((string) $this->request->getGet('abha_id')); + + if ($patientId <= 0 && $abhaId === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id or abha_id is required']); + } + + $builder = $this->db->table('health_records') + ->select('id, patient_id, abha_id, hi_type, entity_type, entity_id, push_status, abdm_txn_id, care_context_reference, consent_handle, push_at, linked_at, created_at') + ->orderBy('id', 'DESC') + ->limit(50); + + if ($patientId > 0) { + $builder->where('patient_id', $patientId); + } + if ($abhaId !== '') { + $builder->where('abha_id', $abhaId); + } + + $rows = $builder->get()->getResultArray(); + + return $this->response->setJSON(['ok' => 1, 'records' => $rows, 'total' => count($rows)]); + } + private function validateWebhookSignature() { $secret = $this->readRuntimeSetting('EKA_WEBHOOK_SECRET'); @@ -783,6 +1192,114 @@ class AbdmGateway extends BaseController return $currentRank > 0 && $incomingRank > 0 && $incomingRank < $currentRank; } + // ========================================================================= + // Private helpers + // ========================================================================= + + /** + * Persist an encrypted FHIR bundle to health_records and return the new row id. + * Returns 0 when the table is absent or on any DB error (fail-open). + * + * @param array{patient_id: int, abha_id: string, hi_type: string, entity_type: string, + * entity_id: string, fhir_bundle: string, consent_handle?: string} $data + */ + private function storeHealthRecord(array $data): int + { + try { + if (! $this->db->tableExists('health_records')) { + return 0; + } + + $enc = new FhirEncryptionService(); + $encPayload = ''; + $rawBundle = (string) ($data['fhir_bundle'] ?? ''); + if ($rawBundle !== '') { + $encPayload = $enc->encrypt($rawBundle); + } + + $session = \Config\Services::session(); + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + + $this->db->table('health_records')->insert([ + 'patient_id' => (int) ($data['patient_id'] ?? 0) > 0 ? (int) $data['patient_id'] : null, + 'abha_id' => trim((string) ($data['abha_id'] ?? '')) ?: null, + 'hi_type' => (string) ($data['hi_type'] ?? 'unknown'), + 'entity_type' => (string) ($data['entity_type'] ?? ''), + 'entity_id' => (string) ($data['entity_id'] ?? ''), + 'fhir_bundle_enc' => $encPayload !== '' ? $encPayload : null, + 'push_status' => 'queued', + 'push_at' => $now, + 'consent_handle' => trim((string) ($data['consent_handle'] ?? '')) ?: null, + 'created_by_user_id' => (int) ($session->get('user_id') ?? 0) > 0 ? (int) $session->get('user_id') : null, + 'created_by_name' => trim((string) ($session->get('full_name') ?? $session->get('name') ?? '')) ?: null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return (int) $this->db->insertID(); + } catch (\Throwable) { + return 0; + } + } + + /** + * Update a health_record's push status and txn_id after getting a connector response. + * Also creates a matching record_links row. + */ + private function updateHealthRecordTxn(int $healthRecordId, string $queueId, ?string $error): void + { + try { + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + + if ($this->db->tableExists('health_records')) { + $this->db->table('health_records') + ->where('id', $healthRecordId) + ->update([ + 'abdm_txn_id' => $queueId !== '' ? $queueId : null, + 'push_status' => $error === null ? 'queued' : 'failed', + 'updated_at' => $now, + ]); + } + + if ($queueId !== '' && $this->db->tableExists('record_links')) { + // Only insert if not already present + $existing = $this->db->table('record_links') + ->where('health_record_id', $healthRecordId) + ->where('abdm_txn_id', $queueId) + ->countAllResults(); + + if ($existing === 0) { + $hr = $this->db->tableExists('health_records') + ? ($this->db->table('health_records')->select('abha_id')->where('id', $healthRecordId)->get(1)->getRowArray() ?? []) + : []; + + $this->db->table('record_links')->insert([ + 'health_record_id' => $healthRecordId, + 'abdm_txn_id' => $queueId, + 'abha_id' => trim((string) ($hr['abha_id'] ?? '')) ?: null, + 'link_status' => 'pending', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } catch (\Throwable) { + // Fail-open + } + } + + /** + * Returns a lazily-constructed AbdmAuditService instance. + */ + private function getAuditService(): AbdmAuditService + { + static $svc = null; + if ($svc === null) { + $svc = new AbdmAuditService(); + } + return $svc; + } + private function getActiveConsentRecord(int $patientId, string $abhaId, string $consentHandle = ''): ?array { if (! $this->db->tableExists('abdm_consent_records')) { diff --git a/app/Controllers/AbdmOpdQueue.php b/app/Controllers/AbdmOpdQueue.php index bf19bf8..f6d9dd8 100644 --- a/app/Controllers/AbdmOpdQueue.php +++ b/app/Controllers/AbdmOpdQueue.php @@ -174,6 +174,16 @@ class AbdmOpdQueue extends BaseController return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]); } + // Mirror status to local DB so list() stays consistent + try { + \Config\Database::connect() + ->table('abdm_opd_tokens') + ->where('gateway_token_id', $tokenId) + ->update(['status' => $status, 'updated_at' => date('Y-m-d H:i:s')]); + } catch (\Throwable $e) { + // non-critical — gateway update already succeeded + } + return $this->response->setJSON($result); } diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index df9fa7a..538168b 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -415,7 +415,9 @@ class Opd_prescription extends BaseController 'complaint_onset' => trim((string) $this->request->getPost('complaint_onset')), 'complaint_duration_days' => trim((string) $this->request->getPost('complaint_duration_days')), 'complaint_severity' => trim((string) $this->request->getPost('complaint_severity')), - 'diagnosis' => trim((string) $this->request->getPost('diagnosis')), + 'complaint_snomed_json' => trim((string) $this->request->getPost('complaint_snomed_json')), + 'diagnosis' => trim((string) $this->request->getPost('diagnosis')), + 'diagnosis_json' => trim((string) $this->request->getPost('diagnosis_json')), 'diagnosis_snomed_id' => trim((string) $this->request->getPost('diagnosis_snomed_id')), 'diagnosis_snomed_term' => trim((string) $this->request->getPost('diagnosis_snomed_term')), 'diagnosis_snomed_source' => trim((string) $this->request->getPost('diagnosis_snomed_source')), @@ -613,15 +615,8 @@ class Opd_prescription extends BaseController } } + // ABDM readiness is collected as non-blocking warnings; save proceeds regardless. $abdmValidationErrors = $this->validateAbdmReadinessForConsult($payload, $patientRow, (array) $opdRow); - if (! empty($abdmValidationErrors)) { - return $this->response->setJSON([ - 'update' => 0, - 'error_text' => implode(' ', $abdmValidationErrors), - 'csrfName' => csrf_token(), - 'csrfHash' => csrf_hash(), - ]); - } foreach ($payload as $value) { if (mb_strlen($value) > 4000) { @@ -704,11 +699,26 @@ class Opd_prescription extends BaseController $this->markOpdVisitedOnConsultCompletion((int) $opdId); + // Queue background SNOMED coding (fail-open — do not block save response) + try { + if ($this->db->tableExists('opd_coding_queue') && $recordId > 0) { + $this->db->query( + "INSERT INTO opd_coding_queue (opd_id, opd_session_id, status, queued_at)" + . " VALUES (?, ?, 'pending', NOW())" + . " ON DUPLICATE KEY UPDATE status = 'pending', queued_at = NOW()", + [(int) $opdId, (int) $recordId] + ); + } + } catch (\Throwable $queueEx) { + log_message('error', 'SNOMED coding queue insert failed: ' . $queueEx->getMessage()); + } + return $this->response->setJSON([ 'update' => 1, 'opd_session_id' => $recordId, 'error_text' => 'Prescription saved', 'fhir_stored' => $fhirStored ? 1 : 0, + 'abdm_warnings' => $abdmValidationErrors, 'saved_at' => date('d-m-Y H:i:s'), 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), @@ -3538,7 +3548,7 @@ class Opd_prescription extends BaseController } $selectCols = $keyField . ' as id, ' . $codeField . ' as code, ' . $nameField . ' as name'; - foreach (['short_name', 'is_favourite', 'spec_ids', 'category_name'] as $f) { + foreach (['short_name', 'is_favourite', 'spec_ids', 'category_name', 'snomed_concept_id', 'snomed_term', 'loinc_code'] as $f) { if (in_array($f, $fields, true)) { $selectCols .= ', ' . $f; } @@ -3608,13 +3618,16 @@ class Opd_prescription extends BaseController return $this->response->setJSON([ 'update' => 1, 'row' => [ - 'id' => (int) ($row[$keyField] ?? 0), - 'code' => (string) ($row[$codeField ?? 'code'] ?? ''), - 'name' => (string) ($row[$nameField ?? 'name'] ?? ''), - 'short_name' => (string) ($row['short_name'] ?? ''), - 'category_name' => (string) ($row['category_name'] ?? ''), - 'is_favourite' => (int) ($row['is_favourite'] ?? 0), - 'spec_ids' => (string) ($row['spec_ids'] ?? ''), + 'id' => (int) ($row[$keyField] ?? 0), + 'code' => (string) ($row[$codeField ?? 'code'] ?? ''), + 'name' => (string) ($row[$nameField ?? 'name'] ?? ''), + 'short_name' => (string) ($row['short_name'] ?? ''), + 'category_name' => (string) ($row['category_name'] ?? ''), + 'is_favourite' => (int) ($row['is_favourite'] ?? 0), + 'spec_ids' => (string) ($row['spec_ids'] ?? ''), + 'snomed_concept_id' => (string) ($row['snomed_concept_id'] ?? ''), + 'snomed_term' => (string) ($row['snomed_term'] ?? ''), + 'loinc_code' => (string) ($row['loinc_code'] ?? ''), ], 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), @@ -3653,7 +3666,7 @@ class Opd_prescription extends BaseController $nameField => $name, $codeField => $code, ]; - foreach (['short_name', 'category_name', 'spec_ids'] as $f) { + foreach (['short_name', 'category_name', 'spec_ids', 'snomed_concept_id', 'snomed_term', 'loinc_code'] as $f) { if (in_array($f, $fields, true)) { $data[$f] = trim((string) $this->request->getPost($f)); } @@ -3733,6 +3746,17 @@ class Opd_prescription extends BaseController ]); } + public function opd_invest_snomed_search() + { + $q = trim((string) $this->request->getGet('q')); + if ($q === '' || strlen($q) < 2) { + return $this->response->setJSON(['rows' => []]); + } + $svc = new \App\Libraries\CsnotkTerminologyService(); + $rows = $svc->searchProcedure($q, 20); + return $this->response->setJSON(['rows' => $rows]); + } + public function opd_invest_shortcuts_manager() { return view('billing/opd_invest_shortcuts_manager'); @@ -4301,16 +4325,31 @@ class Opd_prescription extends BaseController ]); } - return $this->response->setJSON([ - 'status' => 'ok', - 'document_id' => (int) ($row['id'] ?? 0), - 'opd_id' => (int) ($row['opd_id'] ?? 0), - 'opd_session_id' => (int) ($row['opd_session_id'] ?? 0), - 'bundle_type' => (string) ($row['bundle_type'] ?? ''), - 'generated_at' => (string) ($row['generated_at'] ?? ''), - 'generated_by' => (string) ($row['generated_by'] ?? ''), - 'bundle' => $bundle, - ]); + $prettyJson = (string) json_encode($bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $prettyJsonJs = json_encode($prettyJson); + $docId = (int) ($row['id'] ?? 0); + $generatedAt = htmlspecialchars((string) ($row['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8'); + $bundleType = htmlspecialchars((string) ($row['bundle_type'] ?? ''), ENT_QUOTES, 'UTF-8'); + $entryCount = isset($bundle['entry']) && is_array($bundle['entry']) ? count($bundle['entry']) : 0; + + $html = '' + . '' + . 'FHIR Bundle Preview — OPD #' . $opdId . '' + . '' + . '' + . '
' + . '
' + . '
FHIR Bundle Preview
' + . '
' . $bundleType . '' + . '' . $entryCount . ' entries' + . 'Generated: ' . $generatedAt + . '  |  Doc #' . $docId . '  |  OPD #' . $opdId . ' / Session #' . $sessionId . '' + . '
' + . '
'
+            . '
' + . ''; + + return $this->response->setHeader('Content-Type', 'text/html; charset=UTF-8')->setBody($html); } public function advice_search() @@ -4351,70 +4390,100 @@ class Opd_prescription extends BaseController return $this->response->setJSON(['rows' => []]); } - $rows = []; + $out = []; + $seen = []; + $needle = mb_strtolower($this->normalizeAutocompleteSuggestionText($q)); + + // 1. SNOMED CT via CSNOtk (finding / disorder semantic tags) + $csnotkRows = (new CsnotkTerminologyService())->searchFinding($q, 20); + foreach ($csnotkRows as $row) { + $name = $this->normalizeAutocompleteSuggestionText((string) ($row['term'] ?? '')); + if ($name === '') { + continue; + } + $conceptId = trim((string) ($row['concept_id'] ?? '')); + $hierarchy = trim((string) ($row['hierarchy'] ?? '')); + $k = mb_strtoupper($name) . '|' . $conceptId; + if (isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = [ + 'name' => $name, + 'concept_id' => $conceptId, + 'hierarchy' => $hierarchy, + 'source' => 'snomed', + ]; + if (count($out) >= 20) { + return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]); + } + } + + // 2. Local complaints_master fallback if ($this->db->tableExists('complaints_master')) { - $rows = $this->db->table('complaints_master') + $localRows = $this->db->table('complaints_master') ->select('Code as code, Name as name, name_hinglish, show_in_short, ai_hint') ->where('is_active', 1) ->groupStart() - ->like('Name', $q) - ->orLike('name_hinglish', $q) - ->orLike('keywords', $q) + ->like('Name', $q) + ->orLike('name_hinglish', $q) + ->orLike('keywords', $q) ->groupEnd() ->orderBy('show_in_short', 'DESC') ->orderBy('Name', 'ASC') ->limit(20) ->get() ->getResultArray(); + + foreach ($localRows as $row) { + $name = $this->normalizeAutocompleteSuggestionText((string) ($row['name'] ?? '')); + if ($name === '' || ($needle !== '' && mb_stripos($name, $needle) === false)) { + continue; + } + $k = mb_strtoupper($name) . '|'; + if (isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = [ + 'name' => $name, + 'concept_id' => '', + 'hierarchy' => '', + 'source' => 'local', + 'name_hinglish' => (string) ($row['name_hinglish'] ?? ''), + ]; + if (count($out) >= 20) { + return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]); + } + } } + // 3. User autotype keywords $keywordRows = $this->fetchAutotypeKeywords( 'complaints', $q, max(0, (int) ($this->getCurrentUserId() ?? 0)), - 20 + 10 ); - - $out = []; - $seen = []; - $needle = mb_strtolower($this->normalizeAutocompleteSuggestionText($q)); - foreach ($rows as $row) { - $name = $this->normalizeAutocompleteSuggestionText((string) ($row['name'] ?? '')); - if ($name === '') { - continue; - } - if ($needle !== '' && mb_stripos($name, $needle) === false) { - continue; - } - $k = mb_strtoupper($name); - if (isset($seen[$k])) { - continue; - } - $seen[$k] = true; - $row['name'] = $name; - $out[] = $row; - } - foreach ($keywordRows as $keyword) { $name = $this->normalizeAutocompleteSuggestionText((string) $keyword); - if ($name === '') { + if ($name === '' || ($needle !== '' && mb_stripos($name, $needle) === false)) { continue; } - if ($needle !== '' && mb_stripos($name, $needle) === false) { - continue; - } - $k = mb_strtoupper($name); + $k = mb_strtoupper($name) . '|'; if (isset($seen[$k])) { continue; } $seen[$k] = true; $out[] = [ - 'code' => '', - 'name' => $name, - 'name_hinglish' => '', - 'show_in_short' => 0, - 'ai_hint' => '', + 'name' => $name, + 'concept_id' => '', + 'hierarchy' => '', + 'source' => 'keyword', ]; + if (count($out) >= 20) { + break; + } } return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]); @@ -4422,10 +4491,6 @@ class Opd_prescription extends BaseController public function provisional_diagnosis_search() { - if (! $this->canAccessPrescription()) { - return $this->response->setStatusCode(403)->setJSON(['rows' => []]); - } - $q = trim((string) $this->request->getGet('q')); if ($q === '') { return $this->response->setJSON(['rows' => []]); @@ -9079,22 +9144,7 @@ class Opd_prescription extends BaseController $snomedId = trim((string) ($row['provisional_diagnosis_snomed_id'] ?? '')); $snomedTerm = trim((string) ($row['provisional_diagnosis_snomed_term'] ?? '')); - if ($diagnosisSnomedId === '' && $diagnosis !== '' && strpos($diagnosis, ',') === false) { - $resolvedDiagnosis = $this->resolveProvisionalDiagnosisSnomedMatch($diagnosis); - if (! empty($resolvedDiagnosis['concept_id'])) { - $diagnosisSnomedId = (string) ($resolvedDiagnosis['concept_id'] ?? ''); - $diagnosisSnomedTerm = (string) ($resolvedDiagnosis['term'] ?? $diagnosis); - } - } - - if ($snomedId === '' && $provisional !== '' && strpos($provisional, ',') === false) { - $resolved = $this->resolveProvisionalDiagnosisSnomedMatch($provisional); - if (! empty($resolved['concept_id'])) { - $snomedId = (string) ($resolved['concept_id'] ?? ''); - $snomedTerm = (string) ($resolved['term'] ?? $provisional); - } - } - + // Do not perform reverse SNOMED lookups here; use only pre-stored codes to keep save fast. $out = []; $seen = []; @@ -9158,17 +9208,7 @@ class Opd_prescription extends BaseController continue; } $seen[$k] = true; - $complaint = [ - 'text' => $part, - ]; - - $resolvedComplaint = $this->resolveProvisionalDiagnosisSnomedMatch($part); - if (! empty($resolvedComplaint['code'])) { - $complaint['snomed_code'] = (string) $resolvedComplaint['code']; - $complaint['snomed_display'] = (string) ($resolvedComplaint['display'] ?? $part); - } - - $complaints[] = $complaint; + $complaints[] = ['text' => $part]; } } diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index ec4582b..cfd27c0 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -697,8 +697,9 @@ class Patient extends BaseController ->setJSON(['update' => 0, 'error_text' => 'Invalid request']); } - $pid = (int) $this->request->getPost('p_id'); - $abhaId = trim((string) $this->request->getPost('abha_id')); + $pid = (int) $this->request->getPost('p_id'); + $abhaId = trim((string) $this->request->getPost('abha_id')); + $verified = (int) $this->request->getPost('verified'); // 1 = came from OTP flow if ($abhaId !== '' && ! $this->isValidAbhaId($abhaId)) { return $this->response->setJSON([ @@ -716,13 +717,20 @@ class Patient extends BaseController ]); } + // Set abdm_linked_at when ABHA is verified via OTP; clear when manually entered + $pmFields = $this->db->getFieldNames('patient_master') ?? []; + if (in_array('abdm_linked_at', $pmFields, true)) { + $data['abdm_linked_at'] = ($verified === 1 && $abhaId !== '') ? date('Y-m-d H:i:s') : null; + } + $patientModel = new PatientModel(); $patientModel->updatePatient($data, $pid); $this->enqueuePatientAbhaSync($pid, $data, 'patient.abha.updated'); return $this->response->setJSON([ - 'update' => 1, - 'showcontent' => 'Data Saved successfully', + 'update' => 1, + 'showcontent' => 'Data Saved successfully', + 'verified' => $verified === 1 && $abhaId !== '' ? 1 : 0, ]); } diff --git a/app/Database/Migrations/2026-05-17-000205_CreateHealthRecordsAndAuditTables.php b/app/Database/Migrations/2026-05-17-000205_CreateHealthRecordsAndAuditTables.php new file mode 100644 index 0000000..dfda198 --- /dev/null +++ b/app/Database/Migrations/2026-05-17-000205_CreateHealthRecordsAndAuditTables.php @@ -0,0 +1,314 @@ +createHealthRecordsTable(); + $this->createRecordLinksTable(); + $this->createAbdmAuditLogsTable(); + } + + public function down(): void + { + $this->forge->dropTable('abdm_audit_logs', true); + $this->forge->dropTable('record_links', true); + $this->forge->dropTable('health_records', true); + } + + // ------------------------------------------------------------------------- + + private function createHealthRecordsTable(): void + { + if ($this->db->tableExists('health_records')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'patient_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + // ABDM M3 HI type: OPConsultRecord | PrescriptionRecord | + // DiagnosticReport | DischargeSummary | WellnessRecord | + // ImmunizationRecord | HealthDocumentRecord | InvoiceRecord + 'hi_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => false, + ], + // Source module: opd | lab | ipd | wellness | document + 'entity_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + // Primary key of the source record + 'entity_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + // AES-256-GCM encrypted FHIR R4 bundle JSON (base64 IV+TAG+ciphertext) + 'fhir_bundle_enc' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + // Optional path to scanned / uploaded attachment (relative to FCPATH) + 'attachment_path' => [ + 'type' => 'VARCHAR', + 'constraint' => 500, + 'null' => true, + ], + // ABDM gateway queue_id returned after push + 'abdm_txn_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + // pending | queued | pushed | failed | linked + 'push_status' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'pending', + ], + 'push_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'linked_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + // ABDM care-context reference returned on link callback + 'care_context_reference' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + // Consent handle used when this record was pushed + 'consent_handle' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'created_by_user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'created_by_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('patient_id'); + $this->forge->addKey('abha_id'); + $this->forge->addKey('hi_type'); + $this->forge->addKey(['entity_type', 'entity_id'], false, false, 'idx_hr_entity'); + $this->forge->addKey('push_status'); + $this->forge->createTable('health_records', true); + } + + private function createRecordLinksTable(): void + { + if ($this->db->tableExists('record_links')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + // FK to health_records.id + 'health_record_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + // Queue/transaction ID from ABDM gateway + 'abdm_txn_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + // ABDM care-context reference + 'care_context_reference' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + // pending | linked | failed + 'link_status' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'pending', + ], + // Full JSON response from ABDM link callback + 'response_json' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'linked_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('health_record_id'); + $this->forge->addKey('abdm_txn_id'); + $this->forge->addKey('abha_id'); + $this->forge->addKey('link_status'); + $this->forge->createTable('record_links', true); + } + + private function createAbdmAuditLogsTable(): void + { + if ($this->db->tableExists('abdm_audit_logs')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'actor_user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'actor_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + // push_record | link_record | consent_request | consent_grant | + // consent_revoke | record_linked | consent_callback | abha_validate + 'action' => [ + 'type' => 'VARCHAR', + 'constraint' => 60, + 'null' => false, + ], + // opd | lab | ipd | wellness | consent + 'entity_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'entity_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + 'patient_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + // Sanitised request parameters (no PII tokens, no secrets) + 'request_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + // ABDM gateway response + 'response_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + // Requester IP (IPv4 or IPv6) + 'ip_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + 'user_agent' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + // success | failure + 'outcome' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'success', + ], + 'error_message' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('actor_user_id'); + $this->forge->addKey('action'); + $this->forge->addKey('abha_id'); + $this->forge->addKey('patient_id'); + $this->forge->addKey('outcome'); + $this->forge->createTable('abdm_audit_logs', true); + } +} diff --git a/app/Database/Migrations/2026-05-19-000001_AddSnomedFulltextIndex.php b/app/Database/Migrations/2026-05-19-000001_AddSnomedFulltextIndex.php new file mode 100644 index 0000000..ae8b479 --- /dev/null +++ b/app/Database/Migrations/2026-05-19-000001_AddSnomedFulltextIndex.php @@ -0,0 +1,52 @@ +hasTable('snomed_description')) { + return; + } + + // Check if FULLTEXT index already exists + $existing = $this->db->query( + "SHOW INDEX FROM snomed_description WHERE Index_type = 'FULLTEXT' AND Column_name = 'term'" + )->getResultArray(); + + if (! empty($existing)) { + return; // Already present + } + + // Add FULLTEXT index — runs as online DDL (no table lock on InnoDB MySQL 5.6+) + $this->db->query('ALTER TABLE snomed_description ADD FULLTEXT INDEX ft_term (term)'); + } + + public function down(): void + { + if (! $this->hasTable('snomed_description')) { + return; + } + + $existing = $this->db->query( + "SHOW INDEX FROM snomed_description WHERE Key_name = 'ft_term'" + )->getResultArray(); + + if (! empty($existing)) { + $this->db->query('ALTER TABLE snomed_description DROP INDEX ft_term'); + } + } + + private function hasTable(string $table): bool + { + return $this->db->tableExists($table); + } +} diff --git a/app/Database/Migrations/2026-05-19-000002_CreateOpdCodingTables.php b/app/Database/Migrations/2026-05-19-000002_CreateOpdCodingTables.php new file mode 100644 index 0000000..8bb89e1 --- /dev/null +++ b/app/Database/Migrations/2026-05-19-000002_CreateOpdCodingTables.php @@ -0,0 +1,60 @@ +db->tableExists('opd_coding_queue')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'opd_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'opd_session_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'status' => ['type' => 'ENUM', 'constraint' => ['pending', 'processing', 'done', 'failed'], 'default' => 'pending'], + 'has_suggestions'=> ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'queued_at' => ['type' => 'DATETIME', 'null' => true], + 'processed_at' => ['type' => 'DATETIME', 'null' => true], + 'error_message' => ['type' => 'TEXT', 'null' => true], + ]); + $this->forge->addPrimaryKey('id'); + $this->forge->addUniqueKey('opd_session_id'); + $this->forge->addKey(['status', 'queued_at']); + $this->forge->createTable('opd_coding_queue'); + } + + // Suggestions: AI/SNOMED suggestions per phrase per session + if (! $this->db->tableExists('opd_snomed_suggestions')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'opd_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'opd_session_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0], + 'source_field' => ['type' => 'VARCHAR', 'constraint' => 60, 'default' => ''], + 'source_phrase' => ['type' => 'VARCHAR', 'constraint' => 500, 'default' => ''], + 'concept_id' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => ''], + 'snomed_term' => ['type' => 'VARCHAR', 'constraint' => 500, 'default' => ''], + 'semantic_tag' => ['type' => 'VARCHAR', 'constraint' => 100, 'default' => ''], + 'confidence' => ['type' => 'DECIMAL', 'constraint' => '5,3', 'default' => 0], + 'status' => ['type' => 'ENUM', 'constraint' => ['pending_review', 'confirmed', 'rejected', 'corrected'], 'default' => 'pending_review'], + 'corrected_concept_id' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => ''], + 'corrected_term' => ['type' => 'VARCHAR', 'constraint' => 500, 'default' => ''], + 'reviewed_by' => ['type' => 'INT', 'null' => true], + 'reviewed_at' => ['type' => 'DATETIME', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addPrimaryKey('id'); + $this->forge->addKey(['opd_session_id', 'source_field']); + $this->forge->addKey('status'); + $this->forge->createTable('opd_snomed_suggestions'); + } + } + + public function down(): void + { + $this->forge->dropTable('opd_snomed_suggestions', true); + $this->forge->dropTable('opd_coding_queue', true); + } +} diff --git a/app/Database/Migrations/2026-05-19-000210_AddComplaintSnomedJsonToOpdPrescription.php b/app/Database/Migrations/2026-05-19-000210_AddComplaintSnomedJsonToOpdPrescription.php new file mode 100644 index 0000000..7dbaf64 --- /dev/null +++ b/app/Database/Migrations/2026-05-19-000210_AddComplaintSnomedJsonToOpdPrescription.php @@ -0,0 +1,31 @@ +db->getFieldNames('opd_prescription') ?? []; + + if (! in_array('complaint_snomed_json', $fields, true)) { + $this->forge->addColumn('opd_prescription', [ + 'complaint_snomed_json' => [ + 'type' => 'TEXT', + 'null' => true, + 'default' => null, + ], + ]); + } + } + + public function down(): void + { + $fields = $this->db->getFieldNames('opd_prescription') ?? []; + if (in_array('complaint_snomed_json', $fields, true)) { + $this->forge->dropColumn('opd_prescription', 'complaint_snomed_json'); + } + } +} diff --git a/app/Database/Migrations/2026-05-19-000300_AddDiagnosisJsonToOpdPrescription.php b/app/Database/Migrations/2026-05-19-000300_AddDiagnosisJsonToOpdPrescription.php new file mode 100644 index 0000000..caf4468 --- /dev/null +++ b/app/Database/Migrations/2026-05-19-000300_AddDiagnosisJsonToOpdPrescription.php @@ -0,0 +1,32 @@ + [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'diagnosis', + ], + ]; + + if ($this->db->fieldExists('diagnosis_json', 'opd_prescription')) { + return; + } + + $this->forge->addColumn('opd_prescription', $fields); + } + + public function down(): void + { + if ($this->db->fieldExists('diagnosis_json', 'opd_prescription')) { + $this->forge->dropColumn('opd_prescription', 'diagnosis_json'); + } + } +} diff --git a/app/Database/Migrations/2026-05-20-000401_AddSnomedToInvestigation.php b/app/Database/Migrations/2026-05-20-000401_AddSnomedToInvestigation.php new file mode 100644 index 0000000..7591e1a --- /dev/null +++ b/app/Database/Migrations/2026-05-20-000401_AddSnomedToInvestigation.php @@ -0,0 +1,46 @@ +db->tableExists('investigation')) { + return; + } + + $fields = $this->db->getFieldNames('investigation'); + $add = []; + + if (! in_array('snomed_concept_id', $fields, true)) { + $add['snomed_concept_id'] = ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true, 'default' => null]; + } + if (! in_array('snomed_term', $fields, true)) { + $add['snomed_term'] = ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true, 'default' => null]; + } + if (! in_array('loinc_code', $fields, true)) { + $add['loinc_code'] = ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true, 'default' => null]; + } + + if (! empty($add)) { + $this->forge->addColumn('investigation', $add); + } + } + + public function down() + { + if (! $this->db->tableExists('investigation')) { + return; + } + + $fields = $this->db->getFieldNames('investigation'); + foreach (['snomed_concept_id', 'snomed_term', 'loinc_code'] as $col) { + if (in_array($col, $fields, true)) { + $this->forge->dropColumn('investigation', $col); + } + } + } +} diff --git a/app/Libraries/Abdm/AbdmConnectorInterface.php b/app/Libraries/Abdm/AbdmConnectorInterface.php index c840632..e92782a 100644 --- a/app/Libraries/Abdm/AbdmConnectorInterface.php +++ b/app/Libraries/Abdm/AbdmConnectorInterface.php @@ -168,6 +168,25 @@ interface AbdmConnectorInterface array $fullPayload = [] ): array; + // ------------------------------------------------------------------------- + // Health Records — store-and-link flow (POST /api/v3/records/push) + // ------------------------------------------------------------------------- + + /** + * Push a health record to the bridge using the store-and-link flow. + * The bridge stores the record; ABDM links it when the patient uses their PHR app + * (user-initiated) or when HMS calls shareRecord(). + * Returns ['ok'=>1, 'queue_id'=>'REC-...', 'id'=>42, 'status'=>'pending'] on success. + * + * Required keys in $data: + * patient_id, patient_name, hi_type, visit_date, record_data (FHIR bundle or raw JSON) + * Optional: + * abha_id, abha_address, doctor_name, department, care_context_reference, notes + * + * @param array $data + */ + public function pushRecord(array $data): array; + // OPD Queue public function opdQueueFetch(string $date = '', string $status = '', int $page = 1, int $limit = 100): array; public function opdTokenCreate(array $payload): array; diff --git a/app/Libraries/Abdm/DreamsoftConnector.php b/app/Libraries/Abdm/DreamsoftConnector.php index b1fb6b6..29dc322 100644 --- a/app/Libraries/Abdm/DreamsoftConnector.php +++ b/app/Libraries/Abdm/DreamsoftConnector.php @@ -182,6 +182,13 @@ class DreamsoftConnector implements AbdmConnectorInterface return ['ok' => 1, 'queue_id' => $queueId, 'status' => 'queued']; } + public function pushRecord(array $data): array + { + // Dreamsoft bridge does not support store-and-link flow. + // Delegate to sharePrescriptionBundle as a best-effort fallback. + return $this->sharePrescriptionBundle($data, (string) ($data['entity_id'] ?? '')); + } + public function nhcxClaimStatusRequest( int $documentId, string $externalRef, diff --git a/app/Libraries/Abdm/EAtriaBridgeConnector.php b/app/Libraries/Abdm/EAtriaBridgeConnector.php index 8e7f13f..e4035e6 100644 --- a/app/Libraries/Abdm/EAtriaBridgeConnector.php +++ b/app/Libraries/Abdm/EAtriaBridgeConnector.php @@ -269,6 +269,39 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface ]); } + // ------------------------------------------------------------------------- + // Health Records — store-and-link flow POST /api/v3/records/push + // ------------------------------------------------------------------------- + + public function pushRecord(array $data): array + { + // Map HMS hi_type → gateway record_type enum + $hiType = (string) ($data['hi_type'] ?? ''); + $recordType = match (true) { + in_array($hiType, ['OPConsultRecord', 'PrescriptionRecord', 'OPConsultation'], true) => 'prescription', + in_array($hiType, ['DiagnosticReport', 'DiagnosticReportRecord'], true) => 'lab_report', + in_array($hiType, ['DischargeSummary', 'DischargeSummaryRecord'], true) => 'discharge_summary', + $hiType === 'WellnessRecord' => 'wellness_record', + default => 'health_document', + }; + + $body = [ + 'patient_id' => (string) ($data['patient_id'] ?? ''), + 'patient_name' => (string) ($data['patient_name'] ?? ''), + 'record_type' => $recordType, + 'visit_date' => (string) ($data['visit_date'] ?? date('Y-m-d')), + 'record_data' => $data['record_data'] ?? $data['bundle'] ?? (object) [], + ]; + + foreach (['abha_id', 'abha_address', 'doctor_name', 'department', 'care_context_reference', 'notes', 'queue_id'] as $optional) { + if (! empty($data[$optional])) { + $body[$optional] = (string) $data[$optional]; + } + } + + return $this->post('/v3/records/push', $body); + } + // ------------------------------------------------------------------------- // Scan & Share // ------------------------------------------------------------------------- diff --git a/app/Libraries/AbdmAuditService.php b/app/Libraries/AbdmAuditService.php new file mode 100644 index 0000000..24f2604 --- /dev/null +++ b/app/Libraries/AbdmAuditService.php @@ -0,0 +1,102 @@ +db = \Config\Database::connect(); + } + + /** + * Write one audit entry. + * + * @param array{ + * action: string, + * entity_type?: string, + * entity_id?: string|int, + * abha_id?: string, + * patient_id?: int, + * request?: array, + * response?: array, + * outcome?: string, + * error_message?: string, + * actor_user_id?: int, + * actor_name?: string, + * } $data + */ + public function log(array $data): void + { + try { + if (! $this->db->tableExists('abdm_audit_logs')) { + return; + } + + $httpRequest = \Config\Services::request(); + $session = \Config\Services::session(); + + $actorUserId = (int) ($data['actor_user_id'] ?? $session->get('user_id') ?? 0); + $actorName = trim((string) ($data['actor_name'] + ?? $session->get('full_name') + ?? $session->get('name') + ?? '')); + + // Serialise request / response to JSON; never store raw binary data + $requestJson = isset($data['request']) + ? json_encode($data['request'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + : null; + $responseJson = isset($data['response']) + ? json_encode($data['response'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + : null; + + $entityId = (string) ($data['entity_id'] ?? ''); + $patientId = (int) ($data['patient_id'] ?? 0); + + $this->db->table('abdm_audit_logs')->insert([ + 'actor_user_id' => $actorUserId > 0 ? $actorUserId : null, + 'actor_name' => $actorName !== '' ? $actorName : null, + 'action' => (string) ($data['action'] ?? 'unknown'), + 'entity_type' => (string) ($data['entity_type'] ?? ''), + 'entity_id' => $entityId !== '' ? $entityId : null, + 'abha_id' => trim((string) ($data['abha_id'] ?? '')) ?: null, + 'patient_id' => $patientId > 0 ? $patientId : null, + 'request_json' => $requestJson, + 'response_json' => $responseJson, + 'ip_address' => $httpRequest->getIPAddress(), + 'user_agent' => mb_substr((string) $httpRequest->getUserAgent(), 0, 255), + 'outcome' => (string) ($data['outcome'] ?? 'success'), + 'error_message' => trim((string) ($data['error_message'] ?? '')) ?: null, + 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } catch (\Throwable) { + // Fail-open: audit failure must never break the main application flow + } + } +} diff --git a/app/Libraries/CsnotkTerminologyService.php b/app/Libraries/CsnotkTerminologyService.php index c9a93c7..43e2e26 100644 --- a/app/Libraries/CsnotkTerminologyService.php +++ b/app/Libraries/CsnotkTerminologyService.php @@ -2,11 +2,24 @@ namespace App\Libraries; +use CodeIgniter\Database\BaseConnection; + +/** + * SNOMED CT terminology service. + * Priority: local DB (snomed_description + snomed_concept tables). + * Falls back to external csnotk.e-atria.in API only when local tables are absent. + */ class CsnotkTerminologyService { + /** SNOMED type_id for Fully Specified Names (ends with semantic tag in parentheses) */ + private const FSN_TYPE_ID = '900000000000003001'; + private string $baseUrl; private int $timeoutSec; - private bool $enabled; + private bool $apiEnabled; + private ?BaseConnection $db = null; + private bool $localAvailable = false; + private bool $hasFtIndex = false; public function __construct() { @@ -14,179 +27,432 @@ class CsnotkTerminologyService if ($baseUrl === '') { $baseUrl = trim((string) env('CSNOTK_BASE_URL', 'https://csnotk.e-atria.in')); } - $this->baseUrl = rtrim($baseUrl, '/'); + $this->baseUrl = rtrim($baseUrl, '/'); $this->timeoutSec = max(1, (int) env('snomed.csnotk.timeoutSec', 5)); - $enabledRaw = strtolower(trim((string) env('snomed.csnotk.enabled', env('CSNOTK_ENABLED', '')))); - $this->enabled = in_array($enabledRaw, ['1', 'true', 'yes', 'on'], true) || $this->baseUrl !== ''; + $enabledRaw = strtolower(trim((string) env('snomed.csnotk.enabled', env('CSNOTK_ENABLED', '')))); + $this->apiEnabled = in_array($enabledRaw, ['1', 'true', 'yes', 'on'], true) || $this->baseUrl !== ''; + + try { + $db = db_connect(); + if ($db->tableExists('snomed_description') && $db->tableExists('snomed_concept')) { + $this->db = $db; + $this->localAvailable = true; + $this->hasFtIndex = $this->checkFtIndex($db); + } + } catch (\Throwable $e) { + // Local DB not available — will fall through to API + } } public function isEnabled(): bool { - return $this->enabled && $this->baseUrl !== ''; + return $this->localAvailable || ($this->apiEnabled && $this->baseUrl !== ''); } + // ─── Public search API ──────────────────────────────────────────────── + /** + * General diagnosis search (findings + disorders). * @return array */ public function searchDiagnosis(string $query, int $limit = 20): array { $q = trim($query); - if ($q === '' || ! $this->isEnabled()) { + if ($q === '') { + return []; + } + $limit = max(1, min(50, $limit)); + + // Local DB is primary — ~17ms vs ~330ms for external API from server + $rows = $this->localSearch($q, $limit, ['finding', 'disorder', 'morphologic abnormality', 'disease']); + + if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') { + $rows = $this->mergeFallback($rows, $this->apiSearchDiagnosis($q, $limit), $limit); + } + + return array_map(fn ($r) => [ + 'concept_id' => (string) ($r['concept_id'] ?? ''), + 'term' => (string) ($r['term'] ?? ''), + 'source' => (string) ($r['source'] ?? 'local'), + ], array_slice($rows, 0, $limit)); + } + + /** + * Search for clinical findings / symptoms / disorders (for chief complaints autocomplete). + * @return array + */ + public function searchFinding(string $query, int $limit = 20): array + { + $q = trim($query); + if ($q === '') { + return []; + } + $limit = max(1, min(50, $limit)); + + // Local DB is primary — ~17ms vs ~330ms for external API from server + $rows = $this->localSearch($q, $limit, ['finding', 'disorder']); + + if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') { + $rows = $this->mergeFallback($rows, $this->apiSearchBySemTag($q, $limit, ['finding', 'disorder']), $limit); + } + + return array_slice($rows, 0, $limit); + } + + /** + * Search for procedures / observable entities (for investigation advice autocomplete). + * @return array + */ + public function searchProcedure(string $query, int $limit = 20): array + { + $q = trim($query); + if ($q === '') { + return []; + } + $limit = max(1, min(50, $limit)); + + // Local DB is primary — ~17ms vs ~330ms for external API from server + $rows = $this->localSearch($q, $limit, ['procedure', 'observable entity', 'regime/therapy']); + + if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') { + $rows = $this->mergeFallback($rows, $this->apiSearchBySemTag($q, $limit, ['procedure', 'observable entity']), $limit); + } + + return array_slice($rows, 0, $limit); + } + + // ─── Local DB search ────────────────────────────────────────────────── + + /** + * Search local snomed_description. + * Uses FULLTEXT MATCH if index exists, falls back to prefix then substring LIKE. + * + * @param string[] $semanticTags e.g. ['finding','disorder'] — matched against FSN suffix + * @return array + */ + private function localSearch(string $q, int $limit, array $semanticTags = []): array + { + if (! $this->localAvailable || $this->db === null) { return []; } - $limit = max(1, min(50, $limit)); - $rows = []; - $seen = []; + $fetchLimit = max($limit * 5, 100); - $search = $this->requestJson('/api/search/search', [ - 'term' => $q, - 'state' => 'active', - 'acceptability' => 'all', - 'groupbyconcept' => 'true', - 'fullconcept' => 'false', - 'returnlimit' => (string) max(10, $limit * 2), - ]); + // 1. Try FULLTEXT + $dbRows = $this->hasFtIndex ? $this->localFtSearch($q, $fetchLimit) : []; - $items = $this->extractItems($search['data']); - $validated = 0; - $maxValidated = min(10, $limit); + // 2. Try prefix on term_normalized (indexed) + if (empty($dbRows)) { + $dbRows = $this->localPrefixSearch($q, $fetchLimit); + } - foreach ($items as $item) { - if (count($rows) >= $limit) { + // 3. Try substring (slower — full scan but catches mid-word) + if (empty($dbRows)) { + $dbRows = $this->localSubstringSearch($q, $fetchLimit); + } + + return $this->filterAndFormat($dbRows, $semanticTags, $limit); + } + + /** + * FULLTEXT MATCH ... AGAINST (Boolean Mode) search. + * @return array> + */ + private function localFtSearch(string $q, int $limit): array + { + $words = preg_split('/\s+/', mb_strtolower(trim($q))) ?: [$q]; + $ftWords = array_filter($words, fn ($w) => mb_strlen($w) >= 3); + + if (empty($ftWords)) { + // FT cannot handle words shorter than innodb_ft_min_token_size (default 3) + return $this->localPrefixSearch($q, $limit); + } + + $ftQuery = implode(' ', array_map(fn ($w) => '+' . $w . '*', $ftWords)); + + try { + return (array) $this->db->query( + "SELECT d.concept_id, d.term + FROM snomed_description d + INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1 + WHERE d.active = 1 + AND d.language_code = 'en' + AND d.type_id = ? + AND MATCH(d.term) AGAINST(? IN BOOLEAN MODE) + ORDER BY d.term ASC + LIMIT ?", + [self::FSN_TYPE_ID, $ftQuery, $limit] + )->getResultArray(); + } catch (\Throwable $e) { + $this->hasFtIndex = false; // Index may not exist yet — disable for this request + return $this->localPrefixSearch($q, $limit); + } + } + + /** + * Prefix search: term_normalized LIKE 'query%' (uses B-tree index). + * @return array> + */ + private function localPrefixSearch(string $q, int $limit): array + { + $norm = mb_strtolower(trim($q)); + try { + return (array) $this->db->query( + "SELECT d.concept_id, d.term + FROM snomed_description d + INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1 + WHERE d.active = 1 + AND d.language_code = 'en' + AND d.type_id = ? + AND d.term_normalized LIKE ? + ORDER BY d.term ASC + LIMIT ?", + [self::FSN_TYPE_ID, $norm . '%', $limit] + )->getResultArray(); + } catch (\Throwable $e) { + return []; + } + } + + /** + * Substring search: term_normalized LIKE '%query%' (full scan — slower). + * @return array> + */ + private function localSubstringSearch(string $q, int $limit): array + { + $norm = mb_strtolower(trim($q)); + try { + return (array) $this->db->query( + "SELECT d.concept_id, d.term + FROM snomed_description d + INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1 + WHERE d.active = 1 + AND d.language_code = 'en' + AND d.type_id = ? + AND d.term_normalized LIKE ? + ORDER BY d.term ASC + LIMIT ?", + [self::FSN_TYPE_ID, '%' . $norm . '%', $limit] + )->getResultArray(); + } catch (\Throwable $e) { + return []; + } + } + + /** + * Filter raw DB rows by semantic tag and format for output. + * + * @param array> $dbRows + * @param string[] $semanticTags + * @return array + */ + private function filterAndFormat(array $dbRows, array $semanticTags, int $limit): array + { + $tagsLower = array_map('mb_strtolower', $semanticTags); + $out = []; + $seen = []; + + foreach ($dbRows as $row) { + if (count($out) >= $limit) { break; } - $conceptId = $this->readString($item, ['conceptId', 'concept_id', 'id']); - $term = $this->readString($item, ['term', 'conceptFsn', 'name']); - if (! preg_match('/^[0-9]{6,20}$/', $conceptId) || $term === '') { + $conceptId = trim((string) ($row['concept_id'] ?? '')); + $fsn = trim((string) ($row['term'] ?? '')); + if ($conceptId === '' || $fsn === '') { continue; } - if ($validated < $maxValidated) { - $isValid = $this->validateConceptId($conceptId); - $validated++; - if ($isValid === false) { + if (isset($seen[$conceptId])) { + continue; + } + + // Extract semantic tag from FSN suffix: "Fever (finding)" → "finding" + $hierarchy = ''; + $displayTerm = $fsn; + if (preg_match('/^(.*)\(([^)]+)\)$/', $fsn, $m)) { + $hierarchy = trim($m[2]); + $displayTerm = trim($m[1]); + } + + // Apply semantic tag filter + if (! empty($tagsLower)) { + if (! in_array(mb_strtolower($hierarchy), $tagsLower, true)) { continue; } } - $key = strtoupper($term) . '|' . $conceptId; - if (isset($seen[$key])) { - continue; - } - $seen[$key] = true; - - $rows[] = [ + $seen[$conceptId] = true; + $out[] = [ 'concept_id' => $conceptId, - 'term' => $term, - 'source' => 'csnotk', + 'term' => $displayTerm !== '' ? $displayTerm : $fsn, + 'fsn' => $fsn, + 'hierarchy' => $hierarchy, + 'source' => 'local', ]; } - if (count($rows) >= $limit) { - return $rows; - } + return $out; + } - $suggest = $this->requestJson('/api/search/suggest', [ - 'term' => $q, - 'state' => 'active', - 'acceptability' => 'all', - 'returnlimit' => (string) $limit, + // ─── External API fallbacks ─────────────────────────────────────────── + + /** + * @return array + */ + private function apiSearchDiagnosis(string $q, int $limit): array + { + $rows = []; + $seen = []; + + $search = $this->requestJson('/api/search/search', [ + 'term' => $q, + 'state' => 'active', + 'acceptability' => 'all', + 'groupbyconcept' => 'true', + 'fullconcept' => 'false', + 'returnlimit' => (string) max(10, $limit * 2), ]); - $suggestItems = $this->extractItems($suggest['data']); - foreach ($suggestItems as $item) { - if (count($rows) >= $limit) { + foreach ($this->extractItems($search['data']) as $item) { + if (! is_array($item) || count($rows) >= $limit) { break; } - - $term = ''; - if (is_string($item)) { - $term = trim($item); - } elseif (is_array($item)) { - $term = $this->readString($item, ['term', 'name']); - } - if ($term === '') { + $conceptId = $this->readString($item, ['conceptId', 'concept_id', 'id']); + $term = $this->readString($item, ['term', 'conceptFsn', 'name']); + if (! preg_match('/^[0-9]{6,20}$/', $conceptId) || $term === '') { continue; } - - $key = strtoupper($term) . '|'; - if (isset($seen[$key])) { + if (isset($seen[$conceptId])) { continue; } - $seen[$key] = true; - - $rows[] = [ - 'concept_id' => '', - 'term' => $term, - 'source' => 'csnotk-suggest', - ]; + $seen[$conceptId] = true; + $rows[] = ['concept_id' => $conceptId, 'term' => $term, 'fsn' => $term, 'hierarchy' => '', 'source' => 'csnotk']; } return $rows; } - private function validateConceptId(string $conceptId): ?bool - { - $res = $this->requestJson('/api/validate/id', ['id' => $conceptId]); - if (! $res['ok']) { - return null; - } - - $data = $res['data']; - if (is_array($data)) { - if (array_key_exists('valueBoolean', $data)) { - return filter_var($data['valueBoolean'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; - } - if (array_key_exists('valid', $data)) { - return filter_var($data['valid'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; - } - $message = trim((string) ($data['valueString'] ?? $data['message'] ?? '')); - if ($message !== '') { - return stripos($message, ' is valid') !== false; - } - } - - return null; - } - /** - * @param mixed $data - * @return array + * @param string[] $semTags + * @return array */ - private function extractItems($data): array + private function apiSearchBySemTag(string $q, int $limit, array $semTags): array { - if (! is_array($data)) { - return []; - } + $rows = []; + $seen = []; - if ($this->isList($data)) { - return $data; - } + foreach ($semTags as $semTag) { + if (count($rows) >= $limit) { + break; + } + $res = $this->requestJson('/api/search/suggest', [ + 'term' => $q, + 'state' => 'active', + 'semantictag' => $semTag, + 'acceptability' => 'preferred', + 'returnlimit' => (string) max(10, $limit * 2), + ]); - foreach (['rows', 'results', 'data', 'items'] as $key) { - if (isset($data[$key]) && is_array($data[$key])) { - return $this->isList($data[$key]) ? $data[$key] : []; + foreach ($this->extractItems($res['data']) as $item) { + if (count($rows) >= $limit) { + break; + } + $conceptId = ''; + $term = ''; + $fsn = ''; + $hierarchy = $semTag; + + if (is_string($item)) { + $term = trim($item); + } elseif (is_array($item)) { + $conceptId = $this->readString($item, ['conceptId', 'concept_id', 'id']); + $term = $this->readString($item, ['term', 'name']); + $fsn = $this->readString($item, ['conceptFsn', 'fsn', 'fullySpecifiedName']); + $hierarchy = $this->readString($item, ['hierarchy', 'semanticTag', 'semantictag']) ?: $semTag; + $activeStatus = $item['activeStatus'] ?? $item['active_status'] ?? null; + if ($activeStatus !== null && (int) $activeStatus !== 1) { + continue; + } + if (strtolower((string) ($item['conceptState'] ?? $item['concept_state'] ?? 'active')) === 'inactive') { + continue; + } + } + + if ($term === '') { + continue; + } + $displayTerm = $fsn !== '' ? $fsn : $term; + $key = strtoupper($displayTerm) . '|' . $conceptId; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $rows[] = [ + 'concept_id' => $conceptId, + 'term' => $displayTerm, + 'fsn' => $fsn, + 'hierarchy' => $hierarchy, + 'source' => 'csnotk', + ]; } } - return []; + return $rows; } - private function isList(array $arr): bool + // ─── Shared helpers ─────────────────────────────────────────────────── + + /** + * Merge primary results with fallback rows, deduplicating by concept_id. + * + * @param array> $primary + * @param array> $fallback + * @return array> + */ + private function mergeFallback(array $primary, array $fallback, int $limit): array { - if ($arr === []) { - return true; + $seen = []; + foreach ($primary as $r) { + $k = (string) ($r['concept_id'] ?? ''); + if ($k !== '') { + $seen[$k] = true; + } + } + foreach ($fallback as $r) { + if (count($primary) >= $limit) { + break; + } + $k = (string) ($r['concept_id'] ?? ''); + if ($k !== '' && isset($seen[$k])) { + continue; + } + if ($k !== '') { + $seen[$k] = true; + } + $primary[] = $r; } - return array_keys($arr) === range(0, count($arr) - 1); + return $primary; + } + + private function checkFtIndex(BaseConnection $db): bool + { + try { + $rows = $db->query( + "SHOW INDEX FROM snomed_description WHERE Index_type = 'FULLTEXT' AND Column_name = 'term'" + )->getResultArray(); + + return ! empty($rows); + } catch (\Throwable $e) { + return false; + } } /** - * @param array $row - * @param array $keys + * @param array $row + * @param array $keys */ private function readString(array $row, array $keys): string { @@ -204,35 +470,59 @@ class CsnotkTerminologyService } /** - * @param array $query + * @param mixed $data + * @return array + */ + private function extractItems($data): array + { + if (! is_array($data)) { + return []; + } + if ($this->isList($data)) { + return $data; + } + foreach (['rows', 'results', 'data', 'items'] as $key) { + if (isset($data[$key]) && is_array($data[$key])) { + return $this->isList($data[$key]) ? $data[$key] : []; + } + } + + return []; + } + + private function isList(array $arr): bool + { + return $arr === [] || array_keys($arr) === range(0, count($arr) - 1); + } + + /** + * @param array $query * @return array{ok:bool,status:int,body:string,data:mixed} */ private function requestJson(string $path, array $query): array { - if (! $this->isEnabled()) { + if (! $this->apiEnabled || $this->baseUrl === '') { return ['ok' => false, 'status' => 0, 'body' => '', 'data' => null]; } $url = $this->baseUrl . $path; - $qs = http_build_query($query); + $qs = http_build_query($query); if ($qs !== '') { - $url .= (strpos($url, '?') === false ? '?' : '&') . $qs; + $url .= (str_contains($url, '?') ? '&' : '?') . $qs; } $ch = curl_init(); curl_setopt_array($ch, [ - CURLOPT_URL => $url, + CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => $this->timeoutSec, - CURLOPT_TIMEOUT => $this->timeoutSec, - CURLOPT_HTTPHEADER => [ - 'Accept: application/json', - ], + CURLOPT_TIMEOUT => $this->timeoutSec, + CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); - $body = curl_exec($ch); + $body = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); + $error = curl_error($ch); curl_close($ch); if ($body === false || $error !== '') { @@ -242,10 +532,10 @@ class CsnotkTerminologyService $decoded = json_decode((string) $body, true); return [ - 'ok' => $status >= 200 && $status < 300, + 'ok' => $status >= 200 && $status < 300, 'status' => $status, - 'body' => (string) $body, - 'data' => $decoded, + 'body' => (string) $body, + 'data' => $decoded, ]; } } diff --git a/app/Libraries/FhirEncryptionService.php b/app/Libraries/FhirEncryptionService.php new file mode 100644 index 0000000..1f9ee80 --- /dev/null +++ b/app/Libraries/FhirEncryptionService.php @@ -0,0 +1,133 @@ + + * + * Generate a key with: + * php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;" + * + * If ABDM_FHIR_ENCRYPTION_KEY is absent the service falls back to an HMAC + * of the application's existing encryption.key with a fixed domain label. + * This guarantees the service always works, but a dedicated key is strongly + * recommended for production. + */ +class FhirEncryptionService +{ + private const CIPHER = 'aes-256-gcm'; + private const IV_LEN = 12; // 96-bit IV (recommended for GCM) + private const TAG_LEN = 16; // 128-bit authentication tag + + private string $key; + + public function __construct() + { + // Prefer getenv() so the value is available before CI4 bootstraps .env + $keyHex = trim((string) getenv('ABDM_FHIR_ENCRYPTION_KEY')); + if ($keyHex === '') { + $keyHex = trim((string) env('ABDM_FHIR_ENCRYPTION_KEY', '')); + } + + if ($keyHex !== '' && ctype_xdigit($keyHex) && strlen($keyHex) >= 64) { + // Decode exactly 32 bytes from the first 64 hex chars + $this->key = (string) hex2bin(substr($keyHex, 0, 64)); + } else { + // Fallback: derive a 32-byte key from the CI4 application key + $appKey = trim((string) env('encryption.key', (string) env('app.key', ''))); + $this->key = hash_hmac('sha256', 'abdm-fhir-records-v1', $appKey, true); + } + } + + /** + * Encrypt plaintext with AES-256-GCM. + * + * Output format (base64-encoded): IV (12 B) | TAG (16 B) | CIPHERTEXT + * + * @throws \RuntimeException on OpenSSL failure + */ + public function encrypt(string $plaintext): string + { + if ($plaintext === '') { + return ''; + } + + $iv = random_bytes(self::IV_LEN); + $tag = ''; + + $ciphertext = openssl_encrypt( + $plaintext, + self::CIPHER, + $this->key, + OPENSSL_RAW_DATA, + $iv, + $tag, + '', + self::TAG_LEN + ); + + if ($ciphertext === false) { + throw new \RuntimeException( + 'FHIR payload encryption failed: ' . (openssl_error_string() ?: 'unknown OpenSSL error') + ); + } + + return base64_encode($iv . $tag . $ciphertext); + } + + /** + * Decrypt a payload produced by encrypt(). + * + * The GCM authentication tag is verified automatically; a mismatch + * (indicating tampering or a wrong key) throws a RuntimeException. + * + * @throws \RuntimeException on decryption or authentication failure + */ + public function decrypt(string $encoded): string + { + if ($encoded === '') { + return ''; + } + + $raw = base64_decode($encoded, true); + + if ($raw === false || strlen($raw) < self::IV_LEN + self::TAG_LEN + 1) { + throw new \RuntimeException('Invalid encrypted FHIR payload: data is truncated or corrupt'); + } + + $iv = substr($raw, 0, self::IV_LEN); + $tag = substr($raw, self::IV_LEN, self::TAG_LEN); + $ciphertext = substr($raw, self::IV_LEN + self::TAG_LEN); + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $this->key, + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + if ($plaintext === false) { + throw new \RuntimeException( + 'FHIR payload decryption failed: authentication tag mismatch or corrupt data' + ); + } + + return $plaintext; + } + + /** + * Returns true when the current PHP build supports AES-256-GCM. + */ + public static function isSupported(): bool + { + return in_array(self::CIPHER, openssl_get_cipher_methods(), true); + } +} diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index eba443e..34bb8c7 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -719,4 +719,868 @@ class FhirR4Builder return $resource; } + + // ========================================================================= + // Enhanced FHIR Bundle Builders (ABDM M3 Compliant) + // ========================================================================= + + /** + * Build a full DiagnosticReport FHIR Bundle (ABDM M3 — DiagnosticReportRecord). + * + * Supports LOINC-coded observations, specimen metadata, Practitioner performer, + * and an optional scanned-document attachment via DocumentReference + Binary. + * + * @param array $patient Patient demographics + * @param array $diagnosticReport {id, title, category_loinc, + * category_display, status, conclusion, reported_at} + * @param array> $observations Each: {test_name, loinc_code, value, + * value_type (quantity|string), unit, ucum_code, + * reference_range_low, reference_range_high, + * interpretation, status} + * @param array|null $specimen {id, type_text, collection_date} + * @param array|null $attachment {content_type, data_base64, title} + * @param array|null $practitioner {id, name, registration_number} + * @param array|null $organization {id, name, hfr_id} + * + * @return array + */ + public function buildEnhancedLabReportBundle( + array $patient, + array $diagnosticReport, + array $observations = [], + ?array $specimen = null, + ?array $attachment = null, + ?array $practitioner = null, + ?array $organization = null + ): array { + $issuedAt = Time::now('Asia/Kolkata')->toDateTimeString(); + $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown'); + $reportId = 'lab-rpt-' . (string) ($diagnosticReport['id'] ?? date('YmdHis')); + + $entries = [['resource' => $this->buildPatientResource($patient)]]; + + $practitionerRef = ''; + $organizationRef = ''; + + if ($practitioner !== null) { + $practId = trim((string) ($practitioner['id'] ?? '')); + $practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : ''; + if ($practitionerRef !== '') { + $practRes = [ + 'resourceType' => 'Practitioner', + 'id' => $practId, + 'name' => [['text' => trim((string) ($practitioner['name'] ?? ''))]], + ]; + $regNo = trim((string) ($practitioner['registration_number'] ?? '')); + if ($regNo !== '') { + $practRes['identifier'] = [[ + 'system' => 'https://hpr.abdm.gov.in/hpr-id', + 'value' => $regNo, + ]]; + } + $entries[] = ['resource' => $practRes]; + } + } + + if ($organization !== null) { + $orgId = trim((string) ($organization['id'] ?? '')); + $organizationRef = $orgId !== '' ? ('Organization/' . $orgId) : ''; + if ($organizationRef !== '') { + $orgRes = [ + 'resourceType' => 'Organization', + 'id' => $orgId, + 'name' => trim((string) ($organization['name'] ?? '')), + ]; + $hfrId = trim((string) ($organization['hfr_id'] ?? '')); + if ($hfrId !== '') { + $orgRes['identifier'] = [[ + 'system' => 'https://facility.abdm.gov.in/hfr', + 'value' => $hfrId, + ]]; + } + $entries[] = ['resource' => $orgRes]; + } + } + + // Specimen (optional) + $specimenRef = ''; + if ($specimen !== null) { + $specimenId = 'specimen-' . (string) ($specimen['id'] ?? '1'); + $specimenRef = 'Specimen/' . $specimenId; + $specimenRes = [ + 'resourceType' => 'Specimen', + 'id' => $specimenId, + 'subject' => ['reference' => $patientRef], + 'type' => ['text' => (string) ($specimen['type_text'] ?? 'Blood')], + ]; + $collDate = trim((string) ($specimen['collection_date'] ?? '')); + if ($collDate !== '') { + $specimenRes['collection'] = ['collectedDateTime' => $collDate]; + } + $entries[] = ['resource' => $specimenRes]; + } + + // Observations + $observationRefs = []; + foreach ($observations as $index => $obs) { + $obsId = 'obs-' . ($index + 1); + $observationRefs[] = ['reference' => 'Observation/' . $obsId]; + + $loincCode = trim((string) ($obs['loinc_code'] ?? '')); + $testName = (string) ($obs['test_name'] ?? ''); + $valueType = trim((string) ($obs['value_type'] ?? 'string')); + $rawValue = $obs['value'] ?? ''; + $unit = (string) ($obs['unit'] ?? ''); + $ucumCode = (string) ($obs['ucum_code'] ?? $unit); + $interpText = (string) ($obs['interpretation'] ?? ''); + + $code = ['text' => $testName]; + if ($loincCode !== '') { + $code['coding'] = [[ + 'system' => 'http://loinc.org', + 'code' => $loincCode, + 'display' => $testName, + ]]; + } + + $obsResource = [ + 'resourceType' => 'Observation', + 'id' => $obsId, + 'status' => (string) ($obs['status'] ?? 'final'), + 'category' => [[ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', + 'code' => 'laboratory', + 'display' => 'Laboratory', + ]], + ]], + 'code' => $code, + 'subject' => ['reference' => $patientRef], + 'issued' => $issuedAt, + ]; + + if ($specimenRef !== '') { + $obsResource['specimen'] = ['reference' => $specimenRef]; + } + if ($practitionerRef !== '') { + $obsResource['performer'] = [['reference' => $practitionerRef]]; + } + + if ($valueType === 'quantity' && is_numeric($rawValue)) { + $obsResource['valueQuantity'] = [ + 'value' => (float) $rawValue, + 'unit' => $unit, + 'system' => 'http://unitsofmeasure.org', + 'code' => $ucumCode !== '' ? $ucumCode : $unit, + ]; + } else { + $obsResource['valueString'] = (string) $rawValue; + } + + $refLow = $obs['reference_range_low'] ?? null; + $refHigh = $obs['reference_range_high'] ?? null; + if ($refLow !== null || $refHigh !== null) { + $refRange = []; + if (is_numeric($refLow)) { $refRange['low'] = ['value' => (float) $refLow, 'unit' => $unit]; } + if (is_numeric($refHigh)) { $refRange['high'] = ['value' => (float) $refHigh, 'unit' => $unit]; } + if (! empty($refRange)) { + $obsResource['referenceRange'] = [$refRange]; + } + } + + if ($interpText !== '') { + $obsResource['interpretation'] = [['text' => $interpText]]; + } + + $entries[] = ['resource' => $obsResource]; + } + + // Optional scanned attachment + $docRefRef = ''; + if ($attachment !== null && ! empty($attachment['data_base64'])) { + $docEntries = $this->buildDocumentReferenceEntry( + 'doc-lab-' . $reportId, + (string) ($attachment['title'] ?? 'Lab Report Document'), + (string) ($attachment['content_type'] ?? 'application/pdf'), + (string) $attachment['data_base64'], + $patientRef, + $issuedAt + ); + $docRefRef = 'DocumentReference/doc-lab-' . $reportId; + foreach ($docEntries as $entry) { + $entries[] = $entry; + } + } + + $performers = []; + if ($practitionerRef !== '') { $performers[] = ['reference' => $practitionerRef]; } + if ($organizationRef !== '') { $performers[] = ['reference' => $organizationRef]; } + + $categoryLoinc = trim((string) ($diagnosticReport['category_loinc'] ?? '26436-6')); + $categoryDisplay = trim((string) ($diagnosticReport['category_display'] ?? 'Laboratory studies (procedure)')); + + $drResource = [ + 'resourceType' => 'DiagnosticReport', + 'id' => $reportId, + 'status' => (string) ($diagnosticReport['status'] ?? 'final'), + 'category' => [[ + 'coding' => [[ + 'system' => 'http://loinc.org', + 'code' => $categoryLoinc, + 'display' => $categoryDisplay, + ]], + ]], + 'code' => ['text' => (string) ($diagnosticReport['title'] ?? 'Laboratory Report')], + 'subject' => ['reference' => $patientRef], + 'issued' => (string) ($diagnosticReport['reported_at'] ?? $issuedAt), + 'result' => $observationRefs, + 'conclusion' => (string) ($diagnosticReport['conclusion'] ?? ''), + ]; + + if ($specimenRef !== '') { + $drResource['specimen'] = [['reference' => $specimenRef]]; + } + if (! empty($performers)) { + $drResource['performer'] = $performers; + } + if ($docRefRef !== '') { + $drResource['presentedForm'] = [[ + 'contentType' => (string) ($attachment['content_type'] ?? 'application/pdf'), + 'title' => (string) ($attachment['title'] ?? 'Lab Report'), + ]]; + } + $entries[] = ['resource' => $drResource]; + + // Composition (LOINC 11502-2 = Laboratory report) + $compositionSections = [ + ['title' => 'Lab Results', 'entry' => $observationRefs], + ]; + if ($docRefRef !== '') { + $compositionSections[] = [ + 'title' => 'Report Document', + 'entry' => [['reference' => $docRefRef]], + ]; + } + + $composition = [ + 'resourceType' => 'Composition', + 'id' => 'comp-' . $reportId, + 'status' => 'final', + 'type' => [ + 'coding' => [[ + 'system' => 'http://loinc.org', + 'code' => '11502-2', + 'display' => 'Laboratory report', + ]], + 'text' => 'Diagnostic Report - Lab', + ], + 'subject' => ['reference' => $patientRef], + 'date' => $issuedAt, + 'title' => (string) ($diagnosticReport['title'] ?? 'Laboratory Report'), + 'section' => $compositionSections, + ]; + if ($practitionerRef !== '') { + $composition['author'] = [['reference' => $practitionerRef]]; + } + $entries[] = ['resource' => $composition]; + + return [ + 'resourceType' => 'Bundle', + 'identifier' => [ + 'system' => 'urn:ietf:rfc:3986', + 'value' => 'urn:uuid:bundle-lab-' . $reportId . '-' . date('YmdHis'), + ], + 'type' => 'document', + 'timestamp' => $issuedAt, + 'entry' => $entries, + ]; + } + + // ------------------------------------------------------------------------- + + /** + * Build a full Discharge Summary FHIR Bundle (ABDM M3 — DischargeSummaryRecord). + * + * Covers all mandatory composition sections: chief complaints, diagnosis, + * allergies, procedures, medications, investigations, care plan, and a + * free-text clinical notes narrative. + * + * @param array $patient Patient demographics + * @param array $practitioner {id, name, registration_number} + * @param array $organization {id, name, hfr_id} + * @param array $encounter {id, admission_date, discharge_date} + * @param array $summary { + * title, chief_complaints[], conditions[], allergies[], + * procedures[], medications[], investigations[], care_plan, + * follow_up_date, clinical_notes_html + * } + * + * @return array + */ + public function buildEnhancedDischargeSummaryBundle( + array $patient, + array $practitioner, + array $organization, + array $encounter, + array $summary + ): array { + $issuedAt = Time::now('Asia/Kolkata')->toDateTimeString(); + $encounterId = (string) ($encounter['id'] ?? 'enc-' . date('YmdHis')); + $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown'); + $encounterRef = 'Encounter/' . $encounterId; + + $practId = trim((string) ($practitioner['id'] ?? '')); + $orgId = trim((string) ($organization['id'] ?? '')); + $practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : ''; + $organizationRef = $orgId !== '' ? ('Organization/' . $orgId) : ''; + + $entries = [['resource' => $this->buildPatientResource($patient)]]; + + // Practitioner + if ($practitionerRef !== '') { + $practRes = [ + 'resourceType' => 'Practitioner', + 'id' => $practId, + 'name' => [['text' => (string) ($practitioner['name'] ?? '')]], + ]; + $regNo = trim((string) ($practitioner['registration_number'] ?? '')); + if ($regNo !== '') { + $practRes['identifier'] = [[ + 'system' => 'https://hpr.abdm.gov.in/hpr-id', + 'value' => $regNo, + ]]; + } + $entries[] = ['resource' => $practRes]; + } + + // Organization + if ($organizationRef !== '') { + $orgRes = [ + 'resourceType' => 'Organization', + 'id' => $orgId, + 'name' => (string) ($organization['name'] ?? ''), + ]; + $hfrId = trim((string) ($organization['hfr_id'] ?? '')); + if ($hfrId !== '') { + $orgRes['identifier'] = [[ + 'system' => 'https://facility.abdm.gov.in/hfr', + 'value' => $hfrId, + ]]; + } + $entries[] = ['resource' => $orgRes]; + } + + // Encounter (inpatient) + $encounterResource = [ + 'resourceType' => 'Encounter', + 'id' => $encounterId, + 'status' => 'finished', + 'class' => [ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode', + 'code' => 'IMP', + 'display' => 'inpatient encounter', + ], + 'subject' => ['reference' => $patientRef], + 'period' => [ + 'start' => (string) ($encounter['admission_date'] ?? $encounter['period_start'] ?? $issuedAt), + 'end' => (string) ($encounter['discharge_date'] ?? $encounter['period_end'] ?? $issuedAt), + ], + ]; + if ($practitionerRef !== '') { + $encounterResource['participant'] = [['individual' => ['reference' => $practitionerRef]]]; + } + if ($organizationRef !== '') { + $encounterResource['serviceProvider'] = ['reference' => $organizationRef]; + } + $entries[] = ['resource' => $encounterResource]; + + $sections = []; + + // --- Chief Complaints --- + $complaintRefs = []; + foreach ((array) ($summary['chief_complaints'] ?? []) as $idx => $complaint) { + $text = is_array($complaint) + ? trim((string) ($complaint['text'] ?? '')) + : trim((string) $complaint); + if ($text === '') { continue; } + + $condId = 'cc-' . ($idx + 1); + $code = ['text' => $text]; + $snomedCode = is_array($complaint) ? trim((string) ($complaint['snomed_code'] ?? '')) : ''; + if ($snomedCode !== '') { + $code['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $text]]; + } + + $complaintRefs[] = ['reference' => 'Condition/' . $condId]; + $entries[] = ['resource' => [ + 'resourceType' => 'Condition', + 'id' => $condId, + 'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active']]], + 'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-category', 'code' => 'problem-list-item']], 'text' => 'Chief Complaint']], + 'code' => $code, + 'subject' => ['reference' => $patientRef], + 'encounter' => ['reference' => $encounterRef], + ]]; + } + if (! empty($complaintRefs)) { + $sections[] = ['title' => 'Chief Complaints', 'entry' => $complaintRefs]; + } + + // --- Diagnosis / Conditions --- + $conditionRefs = []; + $encounterDiagnosis = []; + foreach ((array) ($summary['conditions'] ?? []) as $idx => $cond) { + $text = is_array($cond) ? trim((string) ($cond['text'] ?? '')) : trim((string) $cond); + if ($text === '') { continue; } + + $condId = 'diag-' . ($idx + 1); + $code = ['text' => $text]; + $snomedCode = is_array($cond) ? trim((string) ($cond['snomed_code'] ?? '')) : ''; + if ($snomedCode !== '') { + $code['coding'] = [[ + 'system' => 'http://snomed.info/sct', + 'code' => $snomedCode, + 'display' => is_array($cond) ? (string) ($cond['snomed_display'] ?? $text) : $text, + ]]; + } + + $verStatus = is_array($cond) && strtolower((string) ($cond['verification_status'] ?? '')) === 'confirmed' + ? 'confirmed' + : 'provisional'; + + $conditionRefs[] = ['reference' => 'Condition/' . $condId]; + $encounterDiagnosis[] = ['condition' => ['reference' => 'Condition/' . $condId]]; + + $entries[] = ['resource' => [ + 'resourceType' => 'Condition', + 'id' => $condId, + 'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active']]], + 'verificationStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', 'code' => $verStatus]]], + 'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-category', 'code' => 'encounter-diagnosis']]]], + 'code' => $code, + 'subject' => ['reference' => $patientRef], + 'encounter' => ['reference' => $encounterRef], + ]]; + } + if (! empty($conditionRefs)) { + $sections[] = ['title' => 'Diagnosis', 'entry' => $conditionRefs]; + // Back-patch encounter diagnosis references + $encounterResource['diagnosis'] = $encounterDiagnosis; + } + + // --- Allergies --- + $allergyRefs = []; + foreach ((array) ($summary['allergies'] ?? []) as $idx => $allergy) { + $codeText = is_array($allergy) + ? trim((string) ($allergy['code_text'] ?? '')) + : trim((string) $allergy); + if ($codeText === '') { continue; } + + $allergyId = 'allergy-ds-' . ($idx + 1); + $allergyRefs[] = ['reference' => 'AllergyIntolerance/' . $allergyId]; + $allergyRes = [ + 'resourceType' => 'AllergyIntolerance', + 'id' => $allergyId, + 'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical', 'code' => 'active']]], + 'verificationStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification', 'code' => 'confirmed']]], + 'patient' => ['reference' => $patientRef], + 'code' => ['text' => $codeText], + ]; + $reaction = is_array($allergy) ? trim((string) ($allergy['reaction_text'] ?? '')) : ''; + if ($reaction !== '') { + $allergyRes['reaction'] = [['description' => $reaction]]; + } + $entries[] = ['resource' => $allergyRes]; + } + if (! empty($allergyRefs)) { + $sections[] = ['title' => 'Allergies', 'entry' => $allergyRefs]; + } + + // --- Procedures --- + $procedureRefs = []; + foreach ((array) ($summary['procedures'] ?? []) as $idx => $procedure) { + $text = is_array($procedure) + ? trim((string) ($procedure['text'] ?? '')) + : trim((string) $procedure); + if ($text === '') { continue; } + + $procId = 'proc-' . ($idx + 1); + $code = ['text' => $text]; + $snomedCode = is_array($procedure) ? trim((string) ($procedure['snomed_code'] ?? '')) : ''; + if ($snomedCode !== '') { + $code['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $text]]; + } + + $procedureRefs[] = ['reference' => 'Procedure/' . $procId]; + $entries[] = ['resource' => [ + 'resourceType' => 'Procedure', + 'id' => $procId, + 'status' => is_array($procedure) ? (string) ($procedure['status'] ?? 'completed') : 'completed', + 'code' => $code, + 'subject' => ['reference' => $patientRef], + 'encounter' => ['reference' => $encounterRef], + ]]; + } + if (! empty($procedureRefs)) { + $sections[] = ['title' => 'Procedures', 'entry' => $procedureRefs]; + } + + // --- Medications --- + $medicationRefs = []; + foreach ((array) ($summary['medications'] ?? []) as $idx => $med) { + $drugName = is_array($med) ? trim((string) ($med['drug_name'] ?? '')) : trim((string) $med); + if ($drugName === '') { continue; } + + $medId = 'medreq-ds-' . ($idx + 1); + $mc = ['text' => $drugName]; + $snomedCode = is_array($med) ? trim((string) ($med['snomed_code'] ?? '')) : ''; + $atcCode = is_array($med) ? strtoupper(trim((string) ($med['atc_code'] ?? ''))) : ''; + if ($snomedCode !== '') { + $mc['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $drugName]]; + } elseif ($atcCode !== '') { + $mc['coding'] = [['system' => 'http://www.whocc.no/atc', 'code' => $atcCode, 'display' => $drugName]]; + } + + $medicationRefs[] = ['reference' => 'MedicationRequest/' . $medId]; + $entries[] = ['resource' => [ + 'resourceType' => 'MedicationRequest', + 'id' => $medId, + 'status' => 'active', + 'intent' => 'order', + 'subject' => ['reference' => $patientRef], + 'encounter' => ['reference' => $encounterRef], + 'medicationCodeableConcept' => $mc, + 'dosageInstruction' => [['text' => is_array($med) ? (string) ($med['dosage'] ?? '') : '']], + ]]; + } + if (! empty($medicationRefs)) { + $sections[] = ['title' => 'Medications', 'entry' => $medicationRefs]; + } + + // --- Investigations --- + $investigationRefs = []; + foreach ((array) ($summary['investigations'] ?? []) as $idx => $inv) { + $text = is_array($inv) ? trim((string) ($inv['text'] ?? '')) : trim((string) $inv); + if ($text === '') { continue; } + + $svcId = 'svc-ds-' . ($idx + 1); + $investigationRefs[] = ['reference' => 'ServiceRequest/' . $svcId]; + $entries[] = ['resource' => [ + 'resourceType' => 'ServiceRequest', + 'id' => $svcId, + 'status' => 'completed', + 'intent' => 'order', + 'code' => ['text' => $text], + 'subject' => ['reference' => $patientRef], + 'encounter' => ['reference' => $encounterRef], + ]]; + } + if (! empty($investigationRefs)) { + $sections[] = ['title' => 'Investigations', 'entry' => $investigationRefs]; + } + + // --- Care Plan --- + $carePlanRefs = []; + $carePlanText = trim((string) ($summary['care_plan'] ?? '')); + if ($carePlanText !== '') { + $cpId = 'careplan-1'; + $carePlanRefs[] = ['reference' => 'CarePlan/' . $cpId]; + $cpResource = [ + 'resourceType' => 'CarePlan', + 'id' => $cpId, + 'status' => 'active', + 'intent' => 'plan', + 'subject' => ['reference' => $patientRef], + 'encounter' => ['reference' => $encounterRef], + 'description' => $carePlanText, + ]; + $followUpDate = trim((string) ($summary['follow_up_date'] ?? '')); + if ($followUpDate !== '') { + $cpResource['period'] = ['start' => $issuedAt, 'end' => $followUpDate]; + } + $entries[] = ['resource' => $cpResource]; + $sections[] = ['title' => 'Care Plan', 'entry' => $carePlanRefs]; + } + + // Clinical notes narrative (unstructured) + $clinicalNotesHtml = (string) ($summary['clinical_notes_html'] ?? ''); + + // Composition (LOINC 18842-5 = Discharge summary) + $composition = [ + 'resourceType' => 'Composition', + 'id' => 'comp-discharge-' . $encounterId, + 'status' => 'final', + 'type' => [ + 'coding' => [[ + 'system' => 'http://loinc.org', + 'code' => '18842-5', + 'display' => 'Discharge summary', + ]], + 'text' => 'Discharge Summary', + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => ['reference' => $encounterRef], + 'date' => $issuedAt, + 'title' => (string) ($summary['title'] ?? 'Discharge Summary'), + 'section' => $sections, + ]; + if ($practitionerRef !== '') { + $composition['author'] = [['reference' => $practitionerRef]]; + } + if ($clinicalNotesHtml !== '') { + $composition['section'][] = [ + 'title' => 'Clinical Summary Notes', + 'text' => ['status' => 'generated', 'div' => $clinicalNotesHtml], + ]; + } + $entries[] = ['resource' => $composition]; + + return [ + 'resourceType' => 'Bundle', + 'identifier' => [ + 'system' => 'urn:ietf:rfc:3986', + 'value' => 'urn:uuid:bundle-discharge-' . $encounterId . '-' . date('YmdHis'), + ], + 'type' => 'document', + 'timestamp' => $issuedAt, + 'entry' => $entries, + ]; + } + + // ------------------------------------------------------------------------- + + /** + * Build a WellnessRecord FHIR Bundle (ABDM M3 — WellnessRecord). + * + * Contains vital-sign / body-measurement Observations and + * social-history lifestyle Observations. + * + * @param array $patient Patient demographics + * @param array> $vitals Each: {loinc_code, display, value, + * value_type (quantity|string), unit, ucum_code} + * @param array> $lifestyle Each: {code, display, value, system} + * @param array|null $practitioner {id, name} + * + * @return array + */ + public function buildWellnessBundle( + array $patient, + array $vitals = [], + array $lifestyle = [], + ?array $practitioner = null + ): array { + $issuedAt = Time::now('Asia/Kolkata')->toDateTimeString(); + $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown'); + $entries = [['resource' => $this->buildPatientResource($patient)]]; + + $practitionerRef = ''; + if ($practitioner !== null) { + $practId = trim((string) ($practitioner['id'] ?? '')); + $practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : ''; + if ($practitionerRef !== '') { + $entries[] = ['resource' => [ + 'resourceType' => 'Practitioner', + 'id' => $practId, + 'name' => [['text' => (string) ($practitioner['name'] ?? '')]], + ]]; + } + } + + $observationRefs = []; + + // Vital signs / body measurements + foreach ($vitals as $idx => $vital) { + $loincCode = trim((string) ($vital['loinc_code'] ?? '')); + $display = (string) ($vital['display'] ?? ''); + $value = $vital['value'] ?? ''; + $unit = (string) ($vital['unit'] ?? ''); + $ucumCode = (string) ($vital['ucum_code'] ?? $unit); + $valueType = (string) ($vital['value_type'] ?? 'quantity'); + + if ($valueType === 'quantity' && ! is_numeric($value)) { continue; } + if (trim((string) $value) === '') { continue; } + + $obsId = 'wellness-obs-' . ($idx + 1); + $observationRefs[] = ['reference' => 'Observation/' . $obsId]; + + $code = ['text' => $display]; + if ($loincCode !== '') { + $code['coding'] = [['system' => 'http://loinc.org', 'code' => $loincCode, 'display' => $display]]; + } + + $obsResource = [ + 'resourceType' => 'Observation', + 'id' => $obsId, + 'status' => 'final', + 'category' => [[ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', + 'code' => 'vital-signs', + 'display' => 'Vital Signs', + ]], + ]], + 'code' => $code, + 'subject' => ['reference' => $patientRef], + 'effectiveDateTime' => $issuedAt, + ]; + + if ($practitionerRef !== '') { + $obsResource['performer'] = [['reference' => $practitionerRef]]; + } + + if ($valueType === 'quantity' && is_numeric($value)) { + $obsResource['valueQuantity'] = [ + 'value' => (float) $value, + 'unit' => $unit, + 'system' => 'http://unitsofmeasure.org', + 'code' => $ucumCode, + ]; + } else { + $obsResource['valueString'] = (string) $value; + } + + $entries[] = ['resource' => $obsResource]; + } + + // Lifestyle / social-history observations + foreach ($lifestyle as $idx => $ls) { + $code = trim((string) ($ls['code'] ?? '')); + $display = (string) ($ls['display'] ?? ''); + $value = (string) ($ls['value'] ?? ''); + $system = (string) ($ls['system'] ?? 'http://snomed.info/sct'); + + if ($value === '' || $display === '') { continue; } + + $obsId = 'wellness-ls-' . ($idx + 1); + $observationRefs[] = ['reference' => 'Observation/' . $obsId]; + + $obsCode = ['text' => $display]; + if ($code !== '') { + $obsCode['coding'] = [['system' => $system, 'code' => $code, 'display' => $display]]; + } + + $entries[] = ['resource' => [ + 'resourceType' => 'Observation', + 'id' => $obsId, + 'status' => 'final', + 'category' => [[ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', + 'code' => 'social-history', + 'display' => 'Social History', + ]], + ]], + 'code' => $obsCode, + 'subject' => ['reference' => $patientRef], + 'effectiveDateTime' => $issuedAt, + 'valueString' => $value, + ]]; + } + + $composition = [ + 'resourceType' => 'Composition', + 'id' => 'comp-wellness-' . date('YmdHis'), + 'status' => 'final', + 'type' => [ + 'coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '371529009', + 'display' => 'Health maintenance report (record artifact)', + ]], + 'text' => 'Wellness Record', + ], + 'subject' => ['reference' => $patientRef], + 'date' => $issuedAt, + 'title' => 'Wellness Record', + 'section' => [ + ['title' => 'Vital Signs and Body Measurements', 'entry' => $observationRefs], + ], + ]; + if ($practitionerRef !== '') { + $composition['author'] = [['reference' => $practitionerRef]]; + } + $entries[] = ['resource' => $composition]; + + return [ + 'resourceType' => 'Bundle', + 'identifier' => [ + 'system' => 'urn:ietf:rfc:3986', + 'value' => 'urn:uuid:bundle-wellness-' . date('YmdHis'), + ], + 'type' => 'document', + 'timestamp' => $issuedAt, + 'entry' => $entries, + ]; + } + + // ------------------------------------------------------------------------- + + /** + * Build DocumentReference + Binary resource entries for a scanned attachment. + * + * Usage: append the returned entries directly to an existing Bundle's entry array. + * + * @param string $id Resource ID stem (without "DocumentReference/") + * @param string $title Document title (human-readable) + * @param string $contentType MIME type — application/pdf | image/jpeg | image/png + * @param string $dataBase64 Base64-encoded raw file content + * @param string $subjectRef Patient reference string (e.g. "Patient/123") + * @param string $date ISO 8601 date/datetime string + * + * @return array> [ [resource => Binary], [resource => DocumentReference] ] + */ + public function buildDocumentReferenceEntry( + string $id, + string $title, + string $contentType, + string $dataBase64, + string $subjectRef, + string $date + ): array { + $binaryId = 'binary-' . $id; + $binaryRef = 'Binary/' . $binaryId; + + $binaryResource = [ + 'resourceType' => 'Binary', + 'id' => $binaryId, + 'contentType' => $contentType, + 'data' => $dataBase64, + ]; + + $docRefResource = [ + 'resourceType' => 'DocumentReference', + 'id' => $id, + 'status' => 'current', + 'type' => ['text' => $title], + 'subject' => ['reference' => $subjectRef], + 'date' => $date, + 'content' => [[ + 'attachment' => [ + 'contentType' => $contentType, + 'url' => $binaryRef, + 'title' => $title, + ], + ]], + ]; + + return [ + ['resource' => $binaryResource], + ['resource' => $docRefResource], + ]; + } + + /** + * Read a file from disk and return its base64-encoded content. + * Returns an empty string when the file is absent or unreadable. + */ + public function encodeFileAsBase64(string $filePath): string + { + if ($filePath === '' || ! is_file($filePath) || ! is_readable($filePath)) { + return ''; + } + + $content = file_get_contents($filePath); + + return $content !== false ? base64_encode($content) : ''; + } } diff --git a/app/Views/abdm/coding_panel.php b/app/Views/abdm/coding_panel.php new file mode 100644 index 0000000..4eb44dd --- /dev/null +++ b/app/Views/abdm/coding_panel.php @@ -0,0 +1,92 @@ + + + + + + <?= esc($page_title) ?> — ABDM + + + + +
+ +
+
+

SNOMED Coding Panel

+
Review AI-matched SNOMED codes before FHIR submission
+
+ ← Task Board +
+ + getFlashdata('message')): ?> +
getFlashdata('message')) ?>
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OPD DateOPD NoPatientTotalPendingConfirmedProcessed At
No coding sessions ready for review.
+
+
+
+ + + 0): ?> + pending + + — + + + 0): ?> + done + + — + + + Review +
+
+
+ +
+ + + diff --git a/app/Views/abdm/coding_panel_review.php b/app/Views/abdm/coding_panel_review.php new file mode 100644 index 0000000..9fd585f --- /dev/null +++ b/app/Views/abdm/coding_panel_review.php @@ -0,0 +1,329 @@ + + + + + + <?= esc($page_title) ?> — ABDM + + + + +
+ + 'Complaints', + 'diagnosis' => 'Diagnosis', + 'Provisional_diagnosis' => 'Provisional Diagnosis', + 'Finding_Examinations' => 'Findings / Examinations', + ]; + + $hasPending = count(array_filter($suggestions, fn($s) => $s['status'] === 'pending_review')) > 0; + $csrfName = csrf_token(); + $csrfHash = csrf_hash(); + ?> + +
+
+

Review SNOMED Codes

+
+ + — OPD + — +
+
+
+ + + + ← Back +
+
+ + +
+
+
+
+ Complaints: + +
+
+ Diagnosis: + +
+
+ Provisional Diagnosis: + +
+
+ Findings: + +
+
+
+
+ + + + $fieldSuggestions): ?> +
+
+ + + + $phrSuggestions): ?> +
+
+ "" + — candidate(s) +
+
+ + +
+
+
+
+
+ + + + + % match +
+
+
+
+ + +
+ Corrected to: + +
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+ + + +
+ + Confirmed + + Rejected + + Corrected + +
+
+ +
+
+ +
+ + + +
No suggestions for this session.
+ + +
+ 0; + ?> + + ← Back +
+ +
+ + + + + diff --git a/app/Views/abdm/opd_queue.php b/app/Views/abdm/opd_queue.php index 5e784ba..6eeb478 100644 --- a/app/Views/abdm/opd_queue.php +++ b/app/Views/abdm/opd_queue.php @@ -1,231 +1,227 @@ - - - - - - OPD Queue — ABDM - - - - -
+ - -
+ +
+
-

OPD Queue ABDM

+

OPD Queue ABDM

Scan & Share arrivals + walk-in tokens — auto-refresh every 30 s
-
- - + - - + + + History +
+
- -
+
- -
+ +
+ + +
Loading…
- +
- + - - + + - - + +
TokenToken Name ABHA Dept Source Status HMS PatientTimeActionsTimeActions
Loading queue…
Loading queue…
-
+
-