From 2b53c05a681591b5f0971e3c2138ae7b73acbaaa Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 16 Jul 2026 18:23:40 +0530 Subject: [PATCH] Add ABDM documents and patient profile sync UI --- app/Config/Routes.php | 12 +- app/Controllers/AbdmHiu.php | 55 +- app/Controllers/Ipd_discharge.php | 8 + app/Controllers/Patient.php | 510 +++++++++++++ ...26-07-16-000715_CreateAbdmHiuDocuments.php | 134 ++++ .../Abdm/M3HiuDocumentRepository.php | 349 +++++++++ app/Libraries/Abdm/M3HiuGatewayClient.php | 45 +- app/Libraries/Abdm/M3HiuWorkflowService.php | 639 ++++------------ .../Abdm/Sync/AbdmGatewayPushClient.php | 2 + app/Views/abdm/hiu_documents.php | 269 +++++++ app/Views/abdm/hiu_m3.php | 684 ++++++++++++++---- app/Views/billing/Patient_Profile_Opd_V.php | 377 ++++++++++ 12 files changed, 2442 insertions(+), 642 deletions(-) create mode 100644 app/Database/Migrations/2026-07-16-000715_CreateAbdmHiuDocuments.php create mode 100644 app/Libraries/Abdm/M3HiuDocumentRepository.php create mode 100644 app/Views/abdm/hiu_documents.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index ef69735..d6309e8 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -496,6 +496,11 @@ $routes->group('billing', function($routes) { $routes->get('patient/show_profile_image/(:num)/(:num)', 'Patient::show_profile_image/$1/$2'); $routes->get('patient/show_profile_opd/(:num)', 'Patient::show_profile_opd/$1'); $routes->get('patient/show_profile_opd/(:num)/(:num)', 'Patient::show_profile_opd/$1/$2'); + $routes->get('patient/abdm_documents/(:num)', 'Patient::abdm_documents/$1'); + $routes->get('patient/abdm_document_detail/(:num)/(:num)', 'Patient::abdm_document_detail/$1/$2'); + $routes->get('patient/abdm_content_request/(:num)', 'Patient::abdm_content_request/$1'); + $routes->get('patient/abdm_content_auto_flow/(:num)', 'Patient::abdm_content_auto_flow/$1'); + $routes->get('patient/abdm_timeline/(:num)', 'Patient::abdm_timeline/$1'); $routes->post('patient/save_profile_image/(:num)', 'Patient::save_profile_image/$1'); $routes->match(['get', 'post'], 'patient/patient_file_upload/(:num)', 'Patient::patient_file_upload/$1'); $routes->get('patient/city', 'Patient::city'); @@ -632,13 +637,14 @@ $routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevok $routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback'); // ABDM M3 HIU (HMS -> Gateway) $routes->get('AbdmHiu', 'AbdmHiu::index', ['filter' => $abdmPermFilter]); +$routes->get('AbdmHiu/documents', 'AbdmHiu::documents', ['filter' => $abdmPermFilter]); $routes->get('AbdmHiu/patient_lookup', 'AbdmHiu::patientLookup', ['filter' => $abdmPermFilter]); +$routes->get('AbdmHiu/documents_list', 'AbdmHiu::documentsList', ['filter' => $abdmPermFilter]); +$routes->get('AbdmHiu/document_detail/(:num)', 'AbdmHiu::documentDetail/$1', ['filter' => $abdmPermFilter]); $routes->get('AbdmHiu/timeline', 'AbdmHiu::timeline', ['filter' => $abdmPermFilter]); $routes->get('AbdmHiu/poll_summary', 'AbdmHiu::pollSummary', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/consent_request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/consent_request_status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]); -$routes->post('AbdmHiu/consent_request_fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]); -$routes->post('AbdmHiu/health_information_request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/consent_reconcile', 'AbdmHiu::consentReconcile', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/data_fetch', 'AbdmHiu::dataFetch', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/consent_update_callback', 'AbdmHiu::consentUpdateWebhook'); @@ -646,13 +652,11 @@ $routes->post('AbdmHiu/health_information_on_request_callback', 'AbdmHiu::health $routes->post('AbdmHiu/health_information_data_push_callback', 'AbdmHiu::healthInformationDataPushWebhook'); $routes->post('api/v3/hiu/consent/request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]); $routes->post('api/v3/hiu/consent/request/status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]); -$routes->post('api/v3/hiu/consent/request/fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]); $routes->post('api/v3/hiu/consent/on-update', 'AbdmHiu::consentUpdateWebhook'); $routes->post('api/v3/hiu/consent/on_update', 'AbdmHiu::consentUpdateWebhook'); $routes->post('api/v3/hiu/health-information/on-request', 'AbdmHiu::healthInformationOnRequestWebhook'); $routes->post('api/v3/hiu/health-information/on_request', 'AbdmHiu::healthInformationOnRequestWebhook'); $routes->post('api/v3/hiu/data/push', 'AbdmHiu::healthInformationDataPushWebhook'); -$routes->post('api/v3/hiu/health-information/request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]); $routes->post('api/v1/hiu/consent/status', 'AbdmHiu::consentReconcile', ['filter' => $abdmPermFilter]); $routes->post('api/v1/hiu/data/fetch', 'AbdmHiu::dataFetch', ['filter' => $abdmPermFilter]); // HMS M2 adapter endpoints (Gateway -> HMS) - keep lowercase v1 path diff --git a/app/Controllers/AbdmHiu.php b/app/Controllers/AbdmHiu.php index 259f443..b2a5e81 100644 --- a/app/Controllers/AbdmHiu.php +++ b/app/Controllers/AbdmHiu.php @@ -3,10 +3,12 @@ namespace App\Controllers; use App\Libraries\Abdm\M3HiuWorkflowService; +use App\Libraries\Abdm\M3HiuDocumentRepository; class AbdmHiu extends BaseController { private M3HiuWorkflowService $service; + private M3HiuDocumentRepository $documentRepository; public function initController( \CodeIgniter\HTTP\RequestInterface $request, @@ -16,6 +18,7 @@ class AbdmHiu extends BaseController parent::initController($request, $response, $logger); $this->db = \Config\Database::connect(); $this->service = new M3HiuWorkflowService(); + $this->documentRepository = new M3HiuDocumentRepository(); } public function index() @@ -23,6 +26,48 @@ class AbdmHiu extends BaseController return view('abdm/hiu_m3'); } + public function documents() + { + return view('abdm/hiu_documents'); + } + + public function documentsList() + { + $filters = [ + 'patient_id' => (int) ($this->request->getGet('patient_id') ?? 0), + 'abha_address' => trim((string) ($this->request->getGet('abha_address') ?? '')), + 'q' => trim((string) ($this->request->getGet('q') ?? '')), + ]; + $limit = (int) ($this->request->getGet('limit') ?? 100); + if ($limit <= 0 || $limit > 300) { + $limit = 100; + } + + $rows = $this->documentRepository->listDocuments($filters, $limit); + return $this->response->setJSON([ + 'ok' => 1, + 'count' => count($rows), + 'items' => $rows, + ]); + } + + public function documentDetail($id = null) + { + $docId = (int) ($id ?? 0); + $row = $this->documentRepository->getDocument($docId); + if (empty($row)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Document not found', + ]); + } + + return $this->response->setJSON([ + 'ok' => 1, + 'item' => $row, + ]); + } + public function consentRequest() { return $this->handleOperation('consent_request', 'REQUESTED'); @@ -33,16 +78,6 @@ class AbdmHiu extends BaseController return $this->handleOperation('consent_status', 'STATUS_CHECKED'); } - public function consentRequestFetch() - { - return $this->handleOperation('consent_fetch', 'CONSENT_FETCHED'); - } - - public function healthInformationRequest() - { - return $this->handleOperation('hi_request', 'DATA_REQUESTED'); - } - public function consentReconcile() { return $this->handleOperation('consent_reconcile', 'STATUS_CHECKED'); diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index 78025dd..ab6714c 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -1942,6 +1942,10 @@ class Ipd_discharge extends BaseController // Decode HTML entities that might have been double-encoded $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + if ($this->looksLikeMalformedDischargeHtml($value)) { + $value = $this->normalizeMalformedDischargeHtmlForMpdf($value); + } return trim($value); } @@ -1972,6 +1976,10 @@ class Ipd_discharge extends BaseController // Check if content contains HTML tags (from HTML editor) if (preg_match('/<(?:p|div|span|strong|b|i|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6])\b[^>]*>/i', $text)) { // Content is HTML - return as-is (already sanitized by normalizeRichText) + if ($this->looksLikeMalformedDischargeHtml($text)) { + $text = $this->normalizeMalformedDischargeHtmlForMpdf($text); + } + return $text; } diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index fde5c2f..d846091 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -1109,6 +1109,452 @@ class Patient extends BaseController ]); } + public function abdm_documents(int $pno) + { + if (! $this->db->tableExists('patient_master')) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'patient_master table not found', + ]); + } + + $patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray(); + if (! is_array($patientRow)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Patient not found', + ]); + } + + if (! $this->db->tableExists('abdm_hiu_documents')) { + return $this->response->setJSON([ + 'ok' => 1, + 'count' => 0, + 'items' => [], + 'abha' => $this->buildAbhaProfileContext($patientRow), + ]); + } + + $limit = (int) ($this->request->getGet('limit') ?? 100); + if ($limit <= 0 || $limit > 300) { + $limit = 100; + } + + $q = trim((string) ($this->request->getGet('q') ?? '')); + $abhaContext = $this->buildAbhaProfileContext($patientRow); + + $builder = $this->db->table('abdm_hiu_documents d') + ->select('d.id, d.patient_id, d.abha_address, d.document_title, d.document_date, d.care_context_reference, d.practitioner_name, d.organization_name, d.bundle_type, d.created_at') + ->orderBy('d.document_date', 'DESC') + ->orderBy('d.id', 'DESC') + ->limit($limit); + + $builder->groupStart() + ->where('d.patient_id', $pno); + if ($abhaContext['abha_address'] !== '') { + $builder->orWhere('d.abha_address', $abhaContext['abha_address']); + } + $builder->groupEnd(); + + if ($q !== '') { + $builder->groupStart() + ->like('d.document_title', $q) + ->orLike('d.care_context_reference', $q) + ->orLike('d.practitioner_name', $q) + ->orLike('d.organization_name', $q) + ->groupEnd(); + } + + $rows = $builder->get()->getResultArray(); + + return $this->response->setJSON([ + 'ok' => 1, + 'count' => count($rows), + 'items' => $rows, + 'abha' => $abhaContext, + ]); + } + + public function abdm_document_detail(int $pno, int $docId) + { + if ($docId <= 0 || ! $this->db->tableExists('abdm_hiu_documents')) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Document not found', + ]); + } + + $patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray(); + if (! is_array($patientRow)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Patient not found', + ]); + } + + $abhaContext = $this->buildAbhaProfileContext($patientRow); + + $builder = $this->db->table('abdm_hiu_documents d') + ->select('*') + ->where('d.id', $docId) + ->groupStart() + ->where('d.patient_id', $pno); + if ($abhaContext['abha_address'] !== '') { + $builder->orWhere('d.abha_address', $abhaContext['abha_address']); + } + $builder->groupEnd(); + + $row = $builder->get(1)->getRowArray(); + if (! is_array($row) || empty($row)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Document not found for patient', + ]); + } + + $row['summary'] = json_decode((string) ($row['summary_json'] ?? ''), true) ?: []; + + return $this->response->setJSON([ + 'ok' => 1, + 'item' => $row, + ]); + } + + public function abdm_content_request(int $pno) + { + if (! $this->db->tableExists('patient_master')) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'patient_master table not found', + ]); + } + + $patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray(); + if (! is_array($patientRow)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Patient not found', + ]); + } + + $abhaContext = $this->buildAbhaProfileContext($patientRow); + if ($abhaContext['abha_address'] === '') { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'error' => 'ABHA Address not available for this patient.', + 'abha' => $abhaContext, + ]); + } + + if ((int) ($abhaContext['is_verified'] ?? 0) !== 1) { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'error' => 'ABHA is not marked as verified in HMS. Please verify ABHA first, then request content.', + 'abha' => $abhaContext, + ]); + } + + $service = new \App\Libraries\Abdm\M3HiuWorkflowService(); + $payload = [ + 'patient_id' => $pno, + 'abha_address' => $abhaContext['abha_address'], + 'hiTypes' => ['OPConsultation', 'Prescription', 'DiagnosticReport', 'HealthDocument'], + ]; + + try { + $result = $service->runOperation('consent_request', $payload); + } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'Unable to start content request: ' . $e->getMessage(), + ]); + } + + $ok = (int) ($result['ok'] ?? 0) === 1; + $httpCode = (int) ($result['http_code'] ?? ($ok ? 200 : 422)); + if ($httpCode < 100 || $httpCode > 599) { + $httpCode = $ok ? 200 : 422; + } + + if (! $ok) { + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 0, + 'error' => (string) ($result['error_text'] ?? 'Content request failed.'), + 'data' => $result, + ]); + } + + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 1, + 'message' => 'Consent/content request created successfully.', + 'workflow_state' => 'REQUESTED', + 'request_id' => (string) ($result['request_id'] ?? ''), + 'data' => $result, + ]); + } + + public function abdm_content_auto_flow(int $pno) + { + if (! $this->db->tableExists('patient_master')) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'patient_master table not found', + ]); + } + + $patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray(); + if (! is_array($patientRow)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Patient not found', + ]); + } + + $abhaContext = $this->buildAbhaProfileContext($patientRow); + if ($abhaContext['abha_address'] === '') { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'error' => 'ABHA Address not available for this patient.', + 'abha' => $abhaContext, + ]); + } + + if ((int) ($abhaContext['is_verified'] ?? 0) !== 1) { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'error' => 'ABHA is not marked as verified in HMS. Please verify ABHA first, then request content.', + 'abha' => $abhaContext, + ]); + } + + $service = new \App\Libraries\Abdm\M3HiuWorkflowService(); + $flowRefId = trim((string) ( + $this->request->getGet('request_id') + ?? $this->request->getPost('request_id') + ?? '' + )); + $consentRequestRef = ''; + $consentArtifactRef = ''; + + $consentResult = null; + $reconcileResult = null; + $fetchResult = null; + + if ($flowRefId === '') { + $consentPayload = [ + 'patient_id' => $pno, + 'abha_address' => $abhaContext['abha_address'], + 'hiTypes' => ['OPConsultation', 'Prescription', 'DiagnosticReport', 'HealthDocument'], + ]; + + try { + $consentResult = $service->runOperation('consent_request', $consentPayload); + } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'Unable to start consent request: ' . $e->getMessage(), + ]); + } + + if ((int) ($consentResult['ok'] ?? 0) !== 1) { + $httpCode = (int) ($consentResult['http_code'] ?? 422); + if ($httpCode < 100 || $httpCode > 599) { + $httpCode = 422; + } + + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 0, + 'phase' => 'REQUEST_FAILED', + 'error' => (string) ($consentResult['error_text'] ?? 'Consent request failed.'), + 'request_id' => (string) ($consentResult['request_id'] ?? ''), + 'data' => $consentResult, + ]); + } + + $flowRefId = trim((string) ( + $consentResult['consent_request_id'] + ?? $consentResult['abdm_consent_request_id'] + ?? $consentResult['consent_id'] + ?? $consentResult['request_id'] + ?? '' + )); + $consentRequestRef = trim((string) ( + $consentResult['consent_request_id'] + ?? $consentResult['abdm_consent_request_id'] + ?? '' + )); + if ($consentRequestRef === '') { + $consentRequestRef = $flowRefId; + } + $consentArtifactRef = trim((string) ( + $consentResult['consent_id'] + ?? $consentResult['abdm_consent_artifact_id'] + ?? '' + )); + if ($consentArtifactRef === '' && $consentRequestRef !== '' && preg_match('/^REQ-/i', $consentRequestRef) !== 1) { + $consentArtifactRef = $consentRequestRef; + } + } + + if ($flowRefId === '') { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'phase' => 'REQUEST_MISSING', + 'error' => 'Unable to determine consent reference for auto flow.', + ]); + } + + $reconcilePayload = [ + 'abha_address' => $abhaContext['abha_address'], + ]; + if ($consentRequestRef !== '') { + $reconcilePayload['consentRequestId'] = $consentRequestRef; + } else { + $reconcilePayload['request_id'] = $flowRefId; + } + + try { + $reconcileResult = $service->runOperation('consent_reconcile', $reconcilePayload); + } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'phase' => 'STATUS_FAILED', + 'error' => 'Unable to reconcile consent status: ' . $e->getMessage(), + 'request_id' => $flowRefId, + ]); + } + + if ((int) ($reconcileResult['ok'] ?? 0) !== 1) { + $httpCode = (int) ($reconcileResult['http_code'] ?? 422); + if ($httpCode < 100 || $httpCode > 599) { + $httpCode = 422; + } + + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 0, + 'phase' => 'STATUS_FAILED', + 'error' => (string) ($reconcileResult['error_text'] ?? 'Consent status check failed.'), + 'request_id' => $flowRefId, + 'data' => [ + 'consent_request' => $consentResult, + 'consent_reconcile' => $reconcileResult, + ], + ]); + } + + $state = $this->deriveAutoFlowState($reconcileResult); + $pollAgain = in_array($state, ['REQUESTED', 'PENDING'], true); + + if ($state === 'GRANTED') { + $fetchPayload = [ + 'abha_address' => $abhaContext['abha_address'], + ]; + if ($consentArtifactRef !== '') { + $fetchPayload['consentId'] = $consentArtifactRef; + } elseif ($consentRequestRef !== '') { + $fetchPayload['consentRequestId'] = $consentRequestRef; + } else { + $fetchPayload['request_id'] = $flowRefId; + } + try { + $fetchResult = $service->runOperation('data_fetch', $fetchPayload); + } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'phase' => 'FETCH_FAILED', + 'error' => 'Unable to fetch ABDM content: ' . $e->getMessage(), + 'request_id' => $flowRefId, + ]); + } + + if ((int) ($fetchResult['ok'] ?? 0) !== 1) { + $httpCode = (int) ($fetchResult['http_code'] ?? 422); + if ($httpCode < 100 || $httpCode > 599) { + $httpCode = 422; + } + + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 0, + 'phase' => 'FETCH_FAILED', + 'error' => (string) ($fetchResult['error_text'] ?? 'Data fetch failed.'), + 'request_id' => $flowRefId, + 'data' => [ + 'consent_request' => $consentResult, + 'consent_reconcile' => $reconcileResult, + 'data_fetch' => $fetchResult, + ], + ]); + } + + $state = 'COMPLETED'; + $pollAgain = false; + } + + $message = match ($state) { + 'COMPLETED' => 'Data fetch completed and documents should now be available.', + 'GRANTED' => 'Consent granted. Proceeding to fetch data.', + 'DENIED' => 'Consent denied/revoked/expired by patient.', + default => 'Consent request created. Waiting for patient approval.', + }; + + return $this->response->setJSON([ + 'ok' => 1, + 'phase' => $state, + 'poll_again' => $pollAgain ? 1 : 0, + 'request_id' => $flowRefId, + 'message' => $message, + 'data' => [ + 'consent_request' => $consentResult, + 'consent_reconcile' => $reconcileResult, + 'data_fetch' => $fetchResult, + ], + ]); + } + + public function abdm_timeline(int $pno) + { + if (! $this->db->tableExists('patient_master')) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'patient_master table not found', + ]); + } + + $patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray(); + if (! is_array($patientRow)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Patient not found', + ]); + } + + $abhaContext = $this->buildAbhaProfileContext($patientRow); + if ($abhaContext['abha_address'] === '') { + return $this->response->setJSON([ + 'ok' => 1, + 'count' => 0, + 'items' => [], + 'note' => 'ABHA address not available for timeline filter.', + ]); + } + + $limit = (int) ($this->request->getGet('limit') ?? 15); + if ($limit <= 0 || $limit > 100) { + $limit = 15; + } + + $service = new \App\Libraries\Abdm\M3HiuWorkflowService(); + $rows = $service->listTimeline([ + 'abha_address' => (string) $abhaContext['abha_address'], + ], $limit); + + return $this->response->setJSON([ + 'ok' => 1, + 'count' => count($rows), + 'items' => $rows, + ]); + } + public function save_profile_image(int $pid) { $patient = $this->db->table('patient_master')->where('id', $pid)->get()->getRow(); @@ -2201,5 +2647,69 @@ class Patient extends BaseController ); } + /** + * @param array $row + * @return array + */ + private function buildAbhaProfileContext(array $row): array + { + $abhaField = $this->resolvePatientAbhaIdField(); + $abhaIdRaw = $abhaField !== null ? trim((string) ($row[$abhaField] ?? '')) : ''; + $abhaAddress = trim((string) ($row['abha_address'] ?? '')); + + if ($abhaAddress === '' && $abhaIdRaw !== '' && $this->isLikelyAbhaAddress($abhaIdRaw)) { + $abhaAddress = $abhaIdRaw; + } + + if ($abhaAddress === '' && preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', (string) ($row['log'] ?? ''), $m) === 1) { + $abhaAddress = trim((string) ($m[1] ?? '')); + } + + $verifiedStatus = strtoupper(trim((string) ($row['abha_verified_status'] ?? ''))); + $kycVerified = (int) ($row['abha_kyc_verified'] ?? 0) === 1; + $mobileVerified = (int) ($row['abha_mobile_verified'] ?? 0) === 1; + $hasStatusColumn = array_key_exists('abha_verified_status', $row); + $isVerified = $verifiedStatus === 'VERIFIED' || ($kycVerified && $mobileVerified); + if (! $hasStatusColumn && $abhaAddress !== '') { + $isVerified = true; + } + + return [ + 'abha_id' => $abhaIdRaw, + 'abha_address' => $abhaAddress, + 'verified_status' => $verifiedStatus, + 'is_verified' => $isVerified ? 1 : 0, + ]; + } + + private function deriveAutoFlowState(array $result): string + { + $blob = strtolower(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''); + + if (str_contains($blob, 'granted') || str_contains($blob, 'approved') || str_contains($blob, 'consent_granted')) { + return 'GRANTED'; + } + + if (str_contains($blob, 'denied') || str_contains($blob, 'revoked') || str_contains($blob, 'expired') || str_contains($blob, 'rejected')) { + return 'DENIED'; + } + + if (str_contains($blob, 'requested') || str_contains($blob, 'pending') || str_contains($blob, 'await')) { + return 'PENDING'; + } + + $explicit = strtoupper(trim((string) ($result['workflow_state'] ?? $result['state'] ?? $result['status'] ?? ''))); + if ($explicit !== '') { + if (in_array($explicit, ['GRANTED', 'APPROVED', 'COMPLETED'], true)) { + return 'GRANTED'; + } + if (in_array($explicit, ['REVOKED', 'EXPIRED', 'DENIED', 'REJECTED'], true)) { + return 'DENIED'; + } + } + + return 'REQUESTED'; + } + } diff --git a/app/Database/Migrations/2026-07-16-000715_CreateAbdmHiuDocuments.php b/app/Database/Migrations/2026-07-16-000715_CreateAbdmHiuDocuments.php new file mode 100644 index 0000000..1bb2663 --- /dev/null +++ b/app/Database/Migrations/2026-07-16-000715_CreateAbdmHiuDocuments.php @@ -0,0 +1,134 @@ +forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'workflow_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'request_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 180, + 'null' => true, + ], + 'transaction_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 180, + 'null' => true, + ], + 'consent_ref' => [ + 'type' => 'VARCHAR', + 'constraint' => 220, + 'null' => true, + ], + 'consent_artifact_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 180, + 'null' => true, + ], + 'abha_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'patient_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 190, + 'null' => true, + ], + 'care_context_reference' => [ + 'type' => 'VARCHAR', + 'constraint' => 190, + 'null' => true, + ], + 'media' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'bundle_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 190, + 'null' => true, + ], + 'bundle_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'document_title' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'document_date' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'practitioner_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 190, + 'null' => true, + ], + 'organization_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 190, + 'null' => true, + ], + 'summary_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'raw_bundle' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'doc_hash' => [ + 'type' => 'CHAR', + 'constraint' => 40, + 'null' => false, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('doc_hash', 'uniq_abdm_hiu_doc_hash'); + $this->forge->addKey(['patient_id', 'document_date'], false, false, 'idx_abdm_hiu_doc_patient_date'); + $this->forge->addKey(['abha_address', 'document_date'], false, false, 'idx_abdm_hiu_doc_abha_date'); + $this->forge->addKey(['request_id', 'created_at'], false, false, 'idx_abdm_hiu_doc_request'); + $this->forge->createTable('abdm_hiu_documents', true); + } + + public function down(): void + { + $this->forge->dropTable('abdm_hiu_documents', true); + } +} diff --git a/app/Libraries/Abdm/M3HiuDocumentRepository.php b/app/Libraries/Abdm/M3HiuDocumentRepository.php new file mode 100644 index 0000000..e666999 --- /dev/null +++ b/app/Libraries/Abdm/M3HiuDocumentRepository.php @@ -0,0 +1,349 @@ +db = \Config\Database::connect(); + } + + public function persistFromDataFetch(array $payload, array $result, int $workflowId = 0): array + { + if (! $this->db->tableExists('abdm_hiu_documents')) { + return ['saved' => 0, 'updated' => 0, 'skipped' => 0]; + } + + $sessions = $this->extractSessions($result); + if (empty($sessions)) { + return ['saved' => 0, 'updated' => 0, 'skipped' => 0]; + } + + $requestId = trim((string) ($result['request_id'] ?? $payload['request_id'] ?? $payload['requestId'] ?? '')); + $abhaAddress = trim((string) ($payload['abha_address'] ?? $result['abha_address'] ?? '')); + + $saved = 0; + $updated = 0; + $skipped = 0; + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + + foreach ($sessions as $session) { + if (! is_array($session)) { + continue; + } + + $transactionId = trim((string) ($session['transaction_id'] ?? '')); + $consentRef = trim((string) ($session['consent_id'] ?? '')); + $consentArtifactId = $this->extractConsentArtifactId($consentRef); + $records = is_array($session['decrypted_data'] ?? null) ? (array) $session['decrypted_data'] : []; + + foreach ($records as $record) { + if (! is_array($record)) { + $skipped++; + continue; + } + + $careContextRef = trim((string) ($record['careContextReference'] ?? '')); + $media = trim((string) ($record['media'] ?? '')); + $bundle = $this->decodeBundle($record['decrypted_data'] ?? null); + if (! is_array($bundle) || empty($bundle)) { + $skipped++; + continue; + } + + $summary = $this->buildSummary($bundle); + $resolvedAbha = $abhaAddress !== '' ? $abhaAddress : trim((string) ($summary['abha_address'] ?? '')); + $patientMap = $this->mapPatientByAbha($resolvedAbha, (string) ($summary['abha_number'] ?? '')); + + $docHash = sha1(implode('|', [ + $requestId, + $transactionId, + $consentRef, + $careContextRef, + (string) ($summary['bundle_id'] ?? ''), + ])); + + $row = [ + 'workflow_id' => $workflowId > 0 ? $workflowId : null, + 'request_id' => $requestId !== '' ? $requestId : null, + 'transaction_id' => $transactionId !== '' ? $transactionId : null, + 'consent_ref' => $consentRef !== '' ? $consentRef : null, + 'consent_artifact_id' => $consentArtifactId !== '' ? $consentArtifactId : null, + 'abha_address' => $resolvedAbha !== '' ? $resolvedAbha : null, + 'patient_id' => (int) ($patientMap['patient_id'] ?? 0) > 0 ? (int) $patientMap['patient_id'] : null, + 'patient_name' => trim((string) ($summary['patient_name'] ?? '')) !== '' ? trim((string) ($summary['patient_name'] ?? '')) : null, + 'care_context_reference' => $careContextRef !== '' ? $careContextRef : null, + 'media' => $media !== '' ? $media : null, + 'bundle_id' => trim((string) ($summary['bundle_id'] ?? '')) !== '' ? trim((string) ($summary['bundle_id'] ?? '')) : null, + 'bundle_type' => trim((string) ($summary['bundle_type'] ?? '')) !== '' ? trim((string) ($summary['bundle_type'] ?? '')) : null, + 'document_title' => trim((string) ($summary['document_title'] ?? '')) !== '' ? trim((string) ($summary['document_title'] ?? '')) : null, + 'document_date' => $this->toDateTimeString((string) ($summary['document_date'] ?? '')), + 'practitioner_name' => trim((string) ($summary['practitioner_name'] ?? '')) !== '' ? trim((string) ($summary['practitioner_name'] ?? '')) : null, + 'organization_name' => trim((string) ($summary['organization_name'] ?? '')) !== '' ? trim((string) ($summary['organization_name'] ?? '')) : null, + 'summary_json' => json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'raw_bundle' => json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => $now, + ]; + + $existing = $this->db->table('abdm_hiu_documents') + ->select('id') + ->where('doc_hash', $docHash) + ->get(1) + ->getRowArray(); + + if (! empty($existing)) { + $this->db->table('abdm_hiu_documents')->where('id', (int) $existing['id'])->update($row); + $updated++; + continue; + } + + $row['doc_hash'] = $docHash; + $row['created_at'] = $now; + $this->db->table('abdm_hiu_documents')->insert($row); + $saved++; + } + } + + return ['saved' => $saved, 'updated' => $updated, 'skipped' => $skipped]; + } + + public function listDocuments(array $filters, int $limit = 100): array + { + if (! $this->db->tableExists('abdm_hiu_documents')) { + return []; + } + + $builder = $this->db->table('abdm_hiu_documents') + ->select('*') + ->orderBy('id', 'DESC') + ->limit($limit); + + $patientId = (int) ($filters['patient_id'] ?? 0); + if ($patientId > 0) { + $builder->where('patient_id', $patientId); + } + + $abhaAddress = trim((string) ($filters['abha_address'] ?? '')); + if ($abhaAddress !== '') { + $builder->where('abha_address', $abhaAddress); + } + + $q = trim((string) ($filters['q'] ?? '')); + if ($q !== '') { + $builder->groupStart() + ->like('patient_name', $q) + ->orLike('care_context_reference', $q) + ->orLike('document_title', $q) + ->orLike('practitioner_name', $q) + ->orLike('organization_name', $q) + ->groupEnd(); + } + + return $builder->get()->getResultArray(); + } + + public function getDocument(int $id): ?array + { + if ($id <= 0 || ! $this->db->tableExists('abdm_hiu_documents')) { + return null; + } + + $row = $this->db->table('abdm_hiu_documents')->where('id', $id)->get(1)->getRowArray(); + if (empty($row)) { + return null; + } + + $row['summary'] = json_decode((string) ($row['summary_json'] ?? ''), true) ?: []; + $row['bundle'] = json_decode((string) ($row['raw_bundle'] ?? ''), true) ?: []; + + return $row; + } + + private function extractSessions(array $result): array + { + $sessions = $result['sessions'] ?? null; + if (is_array($sessions)) { + return $sessions; + } + + $nested = $result['data']['sessions'] ?? null; + return is_array($nested) ? $nested : []; + } + + private function extractConsentArtifactId(string $consentRef): string + { + $consentRef = trim($consentRef); + if ($consentRef === '') { + return ''; + } + $parts = explode(':', $consentRef); + return trim((string) ($parts[0] ?? '')); + } + + private function decodeBundle($raw): array + { + if (is_array($raw)) { + return $raw; + } + + $text = trim((string) $raw); + if ($text === '') { + return []; + } + + $decoded = json_decode($text, true); + return is_array($decoded) ? $decoded : []; + } + + private function buildSummary(array $bundle): array + { + $entries = is_array($bundle['entry'] ?? null) ? (array) $bundle['entry'] : []; + $patient = []; + $composition = []; + $practitioner = []; + $organization = []; + $conditions = []; + $vitals = []; + $medications = []; + + foreach ($entries as $entry) { + $resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : []; + $type = trim((string) ($resource['resourceType'] ?? '')); + if ($type === '') { + continue; + } + + if ($type === 'Patient' && empty($patient)) { + $patient = $resource; + } elseif ($type === 'Composition' && empty($composition)) { + $composition = $resource; + } elseif ($type === 'Practitioner' && empty($practitioner)) { + $practitioner = $resource; + } elseif ($type === 'Organization' && empty($organization)) { + $organization = $resource; + } elseif ($type === 'Condition') { + $conditions[] = [ + 'text' => trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? ''))), + ]; + } elseif ($type === 'Observation') { + $codeText = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? ''))); + $val = ''; + if (isset($resource['valueQuantity']) && is_array($resource['valueQuantity'])) { + $val = trim((string) (($resource['valueQuantity']['value'] ?? '') . ' ' . ($resource['valueQuantity']['unit'] ?? ''))); + } + if ($codeText !== '' || $val !== '') { + $vitals[] = ['name' => $codeText, 'value' => $val]; + } + } elseif ($type === 'MedicationRequest') { + $medications[] = [ + 'name' => trim((string) (($resource['medicationCodeableConcept']['text'] ?? '') ?: ($resource['medicationCodeableConcept']['coding'][0]['display'] ?? ''))), + 'dose' => trim((string) ($resource['dosageInstruction'][0]['text'] ?? '')), + ]; + } + } + + $patientName = trim((string) (($patient['name'][0]['text'] ?? '') ?: implode(' ', (array) ($patient['name'][0]['given'] ?? [])))); + $abhaNumber = ''; + foreach ((array) ($patient['identifier'] ?? []) as $identifier) { + $system = trim((string) ($identifier['system'] ?? '')); + if (stripos($system, 'healthid.ndhm.gov.in') !== false) { + $abhaNumber = preg_replace('/\D+/', '', (string) ($identifier['value'] ?? '')) ?? ''; + break; + } + } + + $practitionerName = trim((string) (($practitioner['name'][0]['text'] ?? '') ?: implode(' ', (array) ($practitioner['name'][0]['given'] ?? [])))); + $organizationName = trim((string) ($organization['name'] ?? '')); + + return [ + 'bundle_id' => trim((string) ($bundle['id'] ?? '')), + 'bundle_type' => trim((string) (($bundle['resourceType'] ?? '') . '/' . ($bundle['type'] ?? ''))), + 'document_title' => trim((string) ($composition['title'] ?? '')), + 'document_date' => trim((string) ($composition['date'] ?? $bundle['timestamp'] ?? '')), + 'patient_name' => $patientName, + 'patient_gender' => trim((string) ($patient['gender'] ?? '')), + 'patient_birth_date' => trim((string) ($patient['birthDate'] ?? '')), + 'abha_number' => $abhaNumber, + 'abha_address' => $abhaNumber !== '' ? ($abhaNumber . '@sbx') : '', + 'practitioner_name' => $practitionerName, + 'organization_name' => $organizationName, + 'conditions' => $conditions, + 'vitals' => $vitals, + 'medications' => $medications, + ]; + } + + private function mapPatientByAbha(string $abhaAddress, string $abhaNumber): array + { + if (! $this->db->tableExists('patient_master')) { + return ['patient_id' => 0]; + } + + $fields = $this->db->getFieldNames('patient_master') ?? []; + $idCol = $this->resolveExistingColumn($fields, ['id']); + if ($idCol === null) { + return ['patient_id' => 0]; + } + + $abhaAddressCol = $this->resolveExistingColumn($fields, ['abha_address', 'abha_addr']); + $abhaNumberCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha_number', 'abha']); + + if ($abhaAddress !== '' && $abhaAddressCol !== null) { + $row = $this->db->table('patient_master') + ->select($idCol . ' AS patient_id') + ->where($abhaAddressCol, $abhaAddress) + ->orderBy($idCol, 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($row)) { + return ['patient_id' => (int) ($row['patient_id'] ?? 0)]; + } + } + + $digits = preg_replace('/\D+/', '', $abhaNumber) ?? ''; + if ($digits !== '' && $abhaNumberCol !== null) { + $row = $this->db->table('patient_master') + ->select($idCol . ' AS patient_id') + ->where($abhaNumberCol, $digits) + ->orderBy($idCol, 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($row)) { + return ['patient_id' => (int) ($row['patient_id'] ?? 0)]; + } + } + + return ['patient_id' => 0]; + } + + private function resolveExistingColumn(array $fields, array $candidates): ?string + { + foreach ($candidates as $candidate) { + if (in_array($candidate, $fields, true)) { + return $candidate; + } + } + + return null; + } + + private function toDateTimeString(string $value): ?string + { + $value = trim($value); + if ($value === '') { + return null; + } + + try { + $dt = new \DateTime($value); + return $dt->format('Y-m-d H:i:s'); + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/app/Libraries/Abdm/M3HiuGatewayClient.php b/app/Libraries/Abdm/M3HiuGatewayClient.php index cb04340..ffa1fd7 100644 --- a/app/Libraries/Abdm/M3HiuGatewayClient.php +++ b/app/Libraries/Abdm/M3HiuGatewayClient.php @@ -41,16 +41,6 @@ class M3HiuGatewayClient return $this->call('/v3/hiu/consent/request/status', $payload, 'consent.request.status'); } - public function consentRequestFetch(array $payload): array - { - return $this->call('/v3/hiu/consent/request/fetch', $payload, 'consent.request.fetch'); - } - - public function healthInformationRequest(array $payload): array - { - return $this->call('/v1/hiu/data/request', $payload, 'health-information.request'); - } - public function reconcileConsentStatus(array $payload): array { return $this->callGet('/v1/hiu/consent/status', $payload, 'consent.status.reconcile'); @@ -121,9 +111,7 @@ class M3HiuGatewayClient if ($curlErr !== '') { $errorText = $curlErr; } elseif ($ok !== 1) { - $errorText = $isJson - ? (string) ($decoded['message'] ?? $decoded['error_text'] ?? $decoded['error'] ?? ('HTTP ' . $httpCode)) - : ('HTTP ' . $httpCode . ' non-JSON response'); + $errorText = $this->extractErrorText($decoded, $httpCode, $isJson); } if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $decoded ?? [])) { @@ -267,9 +255,7 @@ class M3HiuGatewayClient if ($curlErr !== '') { $errorText = $curlErr; } elseif ($ok !== 1) { - $errorText = $isJson - ? (string) ($decoded['message'] ?? $decoded['error_text'] ?? $decoded['error'] ?? ('HTTP ' . $httpCode)) - : ('HTTP ' . $httpCode . ' non-JSON response'); + $errorText = $this->extractErrorText($decoded, $httpCode, $isJson); } if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $decoded ?? [])) { @@ -440,6 +426,33 @@ class M3HiuGatewayClient ]); } + private function extractErrorText($decoded, int $httpCode, bool $isJson): string + { + if (! $isJson || ! is_array($decoded)) { + return 'HTTP ' . $httpCode . ' non-JSON response'; + } + + $code = trim((string) ( + $decoded['data']['error']['code'] + ?? $decoded['error']['code'] + ?? '' + )); + $message = trim((string) ( + $decoded['data']['error']['message'] + ?? $decoded['error']['message'] + ?? $decoded['message'] + ?? $decoded['error_text'] + ?? (is_string($decoded['error'] ?? null) ? $decoded['error'] : '') + )); + + $combined = trim(($code !== '' ? $code . ' ' : '') . $message); + if ($combined !== '') { + return $combined; + } + + return 'HTTP ' . $httpCode; + } + private function findFirstValueByKeys(array $root, array $keys): string { $queue = [$root]; diff --git a/app/Libraries/Abdm/M3HiuWorkflowService.php b/app/Libraries/Abdm/M3HiuWorkflowService.php index f2f8045..db2661a 100644 --- a/app/Libraries/Abdm/M3HiuWorkflowService.php +++ b/app/Libraries/Abdm/M3HiuWorkflowService.php @@ -4,16 +4,19 @@ namespace App\Libraries\Abdm; use CodeIgniter\I18n\Time; use App\Libraries\Abdm\M3HiuGatewayClient; +use App\Libraries\Abdm\M3HiuDocumentRepository; class M3HiuWorkflowService { private \CodeIgniter\Database\BaseConnection $db; private M3HiuGatewayClient $client; + private M3HiuDocumentRepository $documentRepository; public function __construct() { $this->db = \Config\Database::connect(); $this->client = new M3HiuGatewayClient(); + $this->documentRepository = new M3HiuDocumentRepository(); } public function runOperation(string $operation, array $payload): array @@ -21,6 +24,24 @@ class M3HiuWorkflowService $hasWorkflowTable = $this->db->tableExists('abdm_hiu_workflows'); $normalized = $this->normalizePayload($payload); + $retiredError = $this->retiredOperationError($operation, $normalized); + if ($retiredError !== null) { + if ($hasWorkflowTable) { + $this->upsertWorkflow( + $operation, + $normalized, + $retiredError, + 'RETIRED', + 'failed', + (int) ($retiredError['http_code'] ?? 410), + false + ); + $retiredError['workflow_persisted'] = 1; + } + + return $retiredError; + } + $prepared = $this->preparePayloadForOperation($operation, $normalized); if (($prepared['ok'] ?? 0) !== 1) { $failedResult = [ @@ -50,7 +71,7 @@ class M3HiuWorkflowService } $dispatchPayload = (array) ($prepared['payload'] ?? []); - if ($hasWorkflowTable && ! in_array($operation, ['consent_status', 'consent_reconcile', 'data_fetch', 'hi_request'], true)) { + if ($hasWorkflowTable && ! in_array($operation, ['consent_status', 'consent_reconcile', 'data_fetch'], true)) { $duplicate = $this->findDuplicate($operation, $normalized); if ($duplicate !== null) { $dupResponse = json_decode((string) ($duplicate['response_json'] ?? ''), true); @@ -82,8 +103,14 @@ class M3HiuWorkflowService } if ($hasWorkflowTable) { - $this->upsertWorkflow($operation, $persistPayload, $result, $state, $status, $httpCode, $retryable); + $workflowId = $this->upsertWorkflow($operation, $persistPayload, $result, $state, $status, $httpCode, $retryable); $result['workflow_persisted'] = 1; + + if ($operation === 'data_fetch' && $ok) { + $docPersist = $this->documentRepository->persistFromDataFetch($persistPayload, $result, $workflowId); + $result['documents_persisted'] = (int) ($docPersist['saved'] ?? 0); + $result['documents_updated'] = (int) ($docPersist['updated'] ?? 0); + } } else { $result['workflow_persisted'] = 0; $result['workflow_warning'] = 'abdm_hiu_workflows table not found; operation executed without timeline persistence'; @@ -92,6 +119,26 @@ class M3HiuWorkflowService return $result; } + private function retiredOperationError(string $operation, array $payload): ?array + { + if (! in_array($operation, ['consent_fetch', 'hi_request'], true)) { + return null; + } + + return [ + 'ok' => 0, + 'http_code' => 410, + 'error_text' => 'Operation ' . $operation . ' is retired. Use consent_reconcile and data_fetch polling only.', + 'retryable' => 0, + 'request_id' => (string) ($payload['request_id'] ?? ''), + 'transaction_id' => (string) ($payload['transaction_id'] ?? ''), + 'consent_id' => (string) ($payload['consent_id'] ?? ''), + 'abha_address' => (string) ($payload['abha_address'] ?? ''), + 'workflow_state' => 'RETIRED', + 'replacement_ops' => ['consent_reconcile', 'data_fetch'], + ]; + } + public function retryDue(int $limit = 20): array { if (! $this->db->tableExists('abdm_hiu_workflows')) { @@ -122,6 +169,11 @@ class M3HiuWorkflowService continue; } + if (in_array($operation, ['consent_fetch', 'hi_request'], true)) { + $summary['skipped']++; + continue; + } + $result = $this->dispatch($operation, $payload); $ok = (int) ($result['ok'] ?? 0) === 1; $state = $this->resolveState($operation, $result); @@ -179,58 +231,41 @@ class M3HiuWorkflowService if ((int) ($consentResult['ok'] ?? 0) === 1) { $summary['consent_updates']++; - $resolvedConsentId = trim((string) ($consentResult['abdm_consent_artifact_id'] ?? $consentResult['consent_id'] ?? '')); - if ($resolvedConsentId !== '' && ! $this->hasSuccessfulHiRequest($requestId, $resolvedConsentId)) { - if (! $this->shouldAttemptHiRequest($requestId, $resolvedConsentId)) { - $summary['skipped']++; - continue; - } - - $hiPayload = $payload; - $hiPayload['consent_id'] = $resolvedConsentId; - $hiPayload['abdm_consent_artifact_id'] = $resolvedConsentId; - $hiPayload['transaction_id'] = $this->generateFreshHiTransactionId($requestId, trim((string) ($payload['transaction_id'] ?? ''))); - $hiResult = $this->runOperation('hi_request', $hiPayload); - if ((int) ($hiResult['ok'] ?? 0) !== 1) { - $summary['failed']++; - } + $consentState = strtolower(trim((string) ( + $consentResult['workflow_state'] + ?? $consentResult['status'] + ?? $consentResult['consent_status'] + ?? '' + ))); + if ($consentState !== 'granted') { + $summary['skipped']++; + continue; } - } else { - $summary['failed']++; - } - } - $hiRows = $this->db->table('abdm_hiu_workflows') - ->select('*') - ->where('operation', 'hi_request') - ->where('status', 'success') - ->orderBy('id', 'DESC') - ->get($limit) - ->getResultArray(); + $resolvedConsentId = trim((string) ( + $consentResult['abdm_consent_artifact_id'] + ?? $consentResult['consent_id'] + ?? $consentResult['abdm_consent_request_id'] + ?? $consentResult['consent_request_id'] + ?? '' + )); + if ($resolvedConsentId === '' || $this->isGatewayRequestIdPattern($resolvedConsentId)) { + $summary['skipped']++; + continue; + } - $seenTransactions = []; - foreach ($hiRows as $row) { - $transactionId = trim((string) ($row['transaction_id'] ?? '')); - $requestId = trim((string) ($row['request_id'] ?? '')); - $key = $transactionId !== '' ? $transactionId : $requestId; - if ($key === '' || isset($seenTransactions[$key])) { - $summary['skipped']++; - continue; - } - $seenTransactions[$key] = true; + $dataPayload = $payload; + $dataPayload['consent_id'] = $resolvedConsentId; + $dataPayload['consent_request_id'] = $resolvedConsentId; + $dataPayload['abdm_consent_artifact_id'] = $resolvedConsentId; + $dataPayload['abdm_consent_request_id'] = $resolvedConsentId; - $payload = [ - 'request_id' => $requestId, - 'transaction_id' => $transactionId, - 'consent_id' => trim((string) ($row['consent_id'] ?? '')), - 'abdm_consent_artifact_id' => trim((string) ($row['abdm_consent_artifact_id'] ?? '')), - 'abha_address' => trim((string) ($row['abha_address'] ?? '')), - 'hfr_id' => $this->normalizeStoredHfrId((string) ($row['hfr_id'] ?? '')), - ]; - - $dataResult = $this->runOperation('data_fetch', $payload); - if ((int) ($dataResult['ok'] ?? 0) === 1) { - $summary['data_updates']++; + $dataResult = $this->runOperation('data_fetch', $dataPayload); + if ((int) ($dataResult['ok'] ?? 0) === 1) { + $summary['data_updates']++; + } else { + $summary['failed']++; + } } else { $summary['failed']++; } @@ -558,8 +593,6 @@ class M3HiuWorkflowService return match ($operation) { 'consent_request' => $this->client->consentRequest($payload), 'consent_status' => $this->client->consentRequestStatus($payload), - 'consent_fetch' => $this->client->consentRequestFetch($payload), - 'hi_request' => $this->client->healthInformationRequest($payload), 'consent_reconcile' => $this->client->reconcileConsentStatus($payload), 'data_fetch' => $this->client->fetchDecryptedData($payload), default => ['ok' => 0, 'http_code' => 400, 'error_text' => 'Unsupported operation: ' . $operation, 'retryable' => 0], @@ -701,6 +734,7 @@ class M3HiuWorkflowService } } + // Preserve requester.identifier for bridge deployments that validate strict requester schema. $requesterIdentifierValue = trim((string) ($consent['requester']['identifier']['value'] ?? '')); if ($requesterIdentifierValue === '') { $requesterIdentifierValue = $hiuId !== '' ? $hiuId : trim((string) ($clean['hfr_id'] ?? '')); @@ -717,6 +751,15 @@ class M3HiuWorkflowService $consent['requester']['identifier']['value'] = $requesterIdentifierValue; } + $consent['hiTypes'] = $this->normalizeConsentHiTypes($consent['hiTypes'] ?? []); + if (empty($consent['hiTypes'])) { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'consent.hiTypes is required for consent request.', + ]; + } + return [ 'ok' => 1, 'payload' => $base + ['consent' => $consent], @@ -749,83 +792,6 @@ class M3HiuWorkflowService ]; } - if ($operation === 'consent_fetch') { - $consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? '')); - if ($consentId === '') { - $consentId = $this->resolveAbdmConsentArtifactIdFromWorkflow($clean); - } - if ($consentId === '') { - return [ - 'ok' => 0, - 'http_code' => 422, - 'error_text' => 'consentId is required for consent fetch. Consent artifact is not available yet; run Check Consent Status until consent is GRANTED, then fetch using artifact id.', - ]; - } - if ($this->isGatewayRequestIdPattern($consentId)) { - return [ - 'ok' => 0, - 'http_code' => 422, - 'error_text' => 'mapping_error: consentId appears to be a local temporary request id; use ABDM consent artifact id only.', - ]; - } - - return [ - 'ok' => 1, - 'payload' => $base + ['consentId' => $consentId], - ]; - } - - if ($operation === 'hi_request') { - $hiRequest = $clean['hiRequest'] ?? null; - if (! is_array($hiRequest) || empty($hiRequest)) { - $hiRequest = $this->buildDefaultHiRequest($clean); - } - if (! is_array($hiRequest) || empty($hiRequest)) { - return [ - 'ok' => 0, - 'http_code' => 422, - 'error_text' => 'hiRequest object is required for health information request.', - ]; - } - - $consentId = trim((string) ($hiRequest['consent']['id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? '')); - if ($consentId === '') { - return [ - 'ok' => 0, - 'http_code' => 422, - 'error_text' => 'hiRequest.consent.id is required for health information request.', - ]; - } - - if (! isset($hiRequest['consent']) || ! is_array($hiRequest['consent'])) { - $hiRequest['consent'] = []; - } - $hiRequest['consent']['id'] = $consentId; - - $requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? '')); - $transactionId = $this->generateFreshHiTransactionId( - $requestIdRef, - trim((string) ($hiRequest['transactionId'] ?? $clean['transactionId'] ?? $clean['transaction_id'] ?? '')) - ); - $hiRequest['transactionId'] = $transactionId; - - if (! isset($hiRequest['dateRange']) || ! is_array($hiRequest['dateRange'])) { - $hiRequest['dateRange'] = [ - 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-365 days')), - 'to' => gmdate('Y-m-d\T00:00:00.000\Z'), - ]; - } - $hiRequest['dateRange'] = $this->resolveSafeHiDateRange($requestIdRef, $consentId, $hiRequest['dateRange']); - if (trim((string) ($hiRequest['dataPushUrl'] ?? '')) === '') { - $hiRequest['dataPushUrl'] = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/') . '/v3/hiu/data/push'; - } - - return [ - 'ok' => 1, - 'payload' => $base + ['transactionId' => $transactionId, 'transaction_id' => $transactionId, 'hiRequest' => $hiRequest], - ]; - } - if ($operation === 'consent_reconcile') { $requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? '')); $consentRequestId = trim((string) ($clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? '')); @@ -857,27 +823,34 @@ class M3HiuWorkflowService } if ($operation === 'data_fetch') { - $transactionId = trim((string) ($clean['transaction_id'] ?? $clean['transactionId'] ?? '')); - $consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? '')); - if ($consentId !== '' && $this->isGatewayRequestIdPattern($consentId)) { - $consentId = ''; + $consentId = trim((string) ( + $clean['abdm_consent_artifact_id'] + ?? $clean['consentId'] + ?? $clean['consent_id'] + ?? $clean['abdm_consent_request_id'] + ?? $clean['consentRequestId'] + ?? $clean['consent_request_id'] + ?? $clean['request_id'] + ?? $clean['requestId'] + ?? '' + )); + if ($consentId === '') { + $consentId = $this->resolveAbdmConsentArtifactIdFromWorkflow($clean); + } + if ($consentId === '') { + $consentId = $this->resolveAbdmConsentRequestIdFromWorkflow($clean); } - if ($transactionId === '' && $consentId === '') { + if ($consentId === '') { return [ 'ok' => 0, 'http_code' => 422, - 'error_text' => 'One of transaction_id or consent_id is required for data fetch polling.', + 'error_text' => 'consent_id is required for decrypted data fetch polling. Use consent artifact id, consent request id, or original request_id after consent is GRANTED.', ]; } $query = []; - if ($transactionId !== '') { - $query['transaction_id'] = $transactionId; - } - if ($consentId !== '') { - $query['consent_id'] = $consentId; - } + $query['consent_id'] = $consentId; return [ 'ok' => 1, @@ -977,272 +950,55 @@ class M3HiuWorkflowService return $consent; } - private function buildDefaultHiRequest(array $payload): array - { - $consentId = trim((string) ($payload['abdm_consent_artifact_id'] ?? $payload['consentId'] ?? $payload['consent_id'] ?? '')); - if ($consentId === '' || $this->isGatewayRequestIdPattern($consentId)) { - return []; - } - - $requestIdRef = trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? '')); - $transactionId = $this->generateFreshHiTransactionId( - $requestIdRef, - trim((string) ($payload['transactionId'] ?? $payload['transaction_id'] ?? '')) - ); - - return [ - 'consent' => [ - 'id' => $consentId, - ], - 'dateRange' => $this->resolveSafeHiDateRange($requestIdRef, $consentId, [ - 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-365 days')), - 'to' => gmdate('Y-m-d\T00:00:00.000\Z'), - ]), - 'dataPushUrl' => rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/') . '/v3/hiu/data/push', - 'transactionId' => $transactionId, - ]; - } - /** - * Build a safe HI date range by enforcing consent-granted permission window - * (from consent fetch/reconcile details), then clamping with from <= to <= now. + * Normalize legacy/record hiTypes to HIU consent hiTypes accepted by bridge. * - * @param array $fallbackRange - * @return array + * @param mixed $hiTypes + * @return array */ - private function resolveSafeHiDateRange(string $requestIdRef, string $consentId, array $fallbackRange): array + private function normalizeConsentHiTypes($hiTypes): array { - $grantedRange = $this->resolveGrantedConsentDateRange($requestIdRef, $consentId); - $consentRange = ! empty($grantedRange) - ? $grantedRange - : $this->resolveConsentRequestDateRangeByRequestId($requestIdRef); - - $fromRaw = trim((string) ($consentRange['from'] ?? $fallbackRange['from'] ?? '')); - $toRaw = trim((string) ($consentRange['to'] ?? $fallbackRange['to'] ?? '')); - - $nowMs = (int) floor(microtime(true) * 1000); - $fromMs = $this->parseIsoUtcToEpochMillis($fromRaw); - $toMs = $this->parseIsoUtcToEpochMillis($toRaw); - $grantedFromMs = $this->parseIsoUtcToEpochMillis((string) ($grantedRange['from'] ?? '')); - $grantedToMs = $this->parseIsoUtcToEpochMillis((string) ($grantedRange['to'] ?? '')); - - if ($fromMs === null) { - $fromMs = $nowMs - (365 * 24 * 60 * 60 * 1000); + if (is_string($hiTypes)) { + $hiTypes = [$hiTypes]; } - if ($toMs === null) { - $toMs = $nowMs; - } - - // HMS-side requirement: keep HI range equal to or strictly within granted consent permission range. - if ($grantedFromMs !== null && $fromMs < $grantedFromMs) { - $fromMs = $grantedFromMs; - } - if ($grantedToMs !== null && $toMs > $grantedToMs) { - $toMs = $grantedToMs; - } - - if ($toMs > $nowMs) { - $toMs = $nowMs; - } - if ($fromMs > $toMs) { - if ($grantedFromMs !== null && $grantedFromMs <= $toMs) { - $fromMs = $grantedFromMs; - } else { - $fromMs = $toMs; - } - } - - return [ - 'from' => $this->formatEpochMillisToIsoUtc($fromMs), - 'to' => $this->formatEpochMillisToIsoUtc($toMs), - ]; - } - - private function parseIsoUtcToEpochMillis(string $value): ?int - { - $raw = trim($value); - if ($raw === '') { - return null; - } - - try { - $dt = new \DateTimeImmutable($raw); - } catch (\Throwable) { - return null; - } - - $sec = (int) $dt->format('U'); - $micro = (int) $dt->format('u'); - - return ($sec * 1000) + intdiv($micro, 1000); - } - - private function formatEpochMillisToIsoUtc(int $epochMs): string - { - $sec = intdiv($epochMs, 1000); - $ms = $epochMs - ($sec * 1000); - if ($ms < 0) { - $ms = 0; - } - - $dt = (new \DateTimeImmutable('@' . $sec))->setTimezone(new \DateTimeZone('UTC')); - - return $dt->format('Y-m-d\TH:i:s') . '.' . str_pad((string) $ms, 3, '0', STR_PAD_LEFT) . 'Z'; - } - - /** - * @return array{from?: string, to?: string} - */ - private function resolveGrantedConsentDateRange(string $requestId, string $consentId): array - { - if (! $this->db->tableExists('abdm_hiu_workflows')) { + if (! is_array($hiTypes)) { return []; } - $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; - $builder = $this->db->table('abdm_hiu_workflows') - ->select('response_json, request_json') - ->whereIn('operation', ['consent_fetch', 'consent_reconcile', 'consent_status', 'consent_callback']); - - $hasFilter = false; - $builder->groupStart(); - if ($requestId !== '') { - $builder->orWhere('request_id', $requestId); - $hasFilter = true; - } - if ($consentId !== '') { - $builder->orWhere('consent_id', $consentId); - $hasFilter = true; - if (in_array('abdm_consent_artifact_id', $fields, true)) { - $builder->orWhere('abdm_consent_artifact_id', $consentId); - } - } - $builder->groupEnd(); - - if (! $hasFilter) { - return []; - } - - $rows = $builder->orderBy('id', 'DESC')->get(30)->getResultArray(); - foreach ($rows as $row) { - foreach (['response_json', 'request_json'] as $jsonCol) { - $decoded = json_decode((string) ($row[$jsonCol] ?? ''), true); - if (! is_array($decoded)) { - continue; - } - - $range = $this->extractPermissionDateRange($decoded); - if (! empty($range)) { - return $range; - } - } - } - - return []; - } - - /** - * @param array $data - * @return array{from?: string, to?: string} - */ - private function extractPermissionDateRange(array $data): array - { - $pathCandidates = [ - $data['consent']['permission']['dateRange'] ?? null, - $data['consentDetail']['permission']['dateRange'] ?? null, - $data['data']['consent']['permission']['dateRange'] ?? null, - $data['data']['consentDetail']['permission']['dateRange'] ?? null, - $data['response']['consent']['permission']['dateRange'] ?? null, - $data['response']['consentDetail']['permission']['dateRange'] ?? null, + $map = [ + 'opconsultrecord' => 'OPConsultation', + 'opconsultation' => 'OPConsultation', + 'diagnosticreportrecord' => 'DiagnosticReport', + 'diagnosticreport' => 'DiagnosticReport', + 'prescriptionrecord' => 'Prescription', + 'prescription' => 'Prescription', + 'dischargesummaryrecord' => 'DischargeSummary', + 'dischargesummary' => 'DischargeSummary', + 'healthdocumentrecord' => 'HealthDocument', + 'healthdocument' => 'HealthDocument', + 'immunization' => 'ImmunizationRecord', + 'immunizationrecord' => 'ImmunizationRecord', + 'wellness' => 'Wellness', + 'wellnessrecord' => 'Wellness', + 'invoice' => 'Invoice', + 'invoicerecord' => 'Invoice', ]; - foreach ($pathCandidates as $candidate) { - if (is_array($candidate)) { - $from = trim((string) ($candidate['from'] ?? '')); - $to = trim((string) ($candidate['to'] ?? '')); - if ($from !== '' || $to !== '') { - $out = []; - if ($from !== '') { - $out['from'] = $from; - } - if ($to !== '') { - $out['to'] = $to; - } - return $out; - } - } - } - - if (isset($data['permission']) && is_array($data['permission'])) { - $range = $data['permission']['dateRange'] ?? null; - if (is_array($range)) { - $from = trim((string) ($range['from'] ?? '')); - $to = trim((string) ($range['to'] ?? '')); - if ($from !== '' || $to !== '') { - $out = []; - if ($from !== '') { - $out['from'] = $from; - } - if ($to !== '') { - $out['to'] = $to; - } - return $out; - } - } - } - - foreach ($data as $value) { - if (! is_array($value)) { + $normalized = []; + foreach ($hiTypes as $type) { + $raw = trim((string) $type); + if ($raw === '') { continue; } - $nested = $this->extractPermissionDateRange($value); - if (! empty($nested)) { - return $nested; + $key = strtolower($raw); + $resolved = $map[$key] ?? $raw; + if (! in_array($resolved, $normalized, true)) { + $normalized[] = $resolved; } } - return []; - } - - /** - * @return array{from?: string, to?: string} - */ - private function resolveConsentRequestDateRangeByRequestId(string $requestId): array - { - if ($requestId === '' || ! $this->db->tableExists('abdm_hiu_workflows')) { - return []; - } - - $row = $this->db->table('abdm_hiu_workflows') - ->select('request_json') - ->where('operation', 'consent_request') - ->where('request_id', $requestId) - ->orderBy('id', 'DESC') - ->get(1) - ->getRowArray(); - - if (empty($row['request_json'])) { - return []; - } - - $decoded = json_decode((string) $row['request_json'], true); - if (! is_array($decoded)) { - return []; - } - - $from = trim((string) ($decoded['consent']['permission']['dateRange']['from'] ?? '')); - $to = trim((string) ($decoded['consent']['permission']['dateRange']['to'] ?? '')); - - $out = []; - if ($from !== '') { - $out['from'] = $from; - } - if ($to !== '') { - $out['to'] = $to; - } - - return $out; + return $normalized; } private function readSettingValue(string $key): string @@ -1460,102 +1216,6 @@ class M3HiuWorkflowService return null; } - private function hasSuccessfulHiRequest(string $requestId, string $consentId): bool - { - if (! $this->db->tableExists('abdm_hiu_workflows')) { - return false; - } - - $builder = $this->db->table('abdm_hiu_workflows') - ->select('id') - ->where('operation', 'hi_request') - ->where('status', 'success'); - - $builder->groupStart(); - if ($requestId !== '') { - $builder->orWhere('request_id', $requestId); - } - if ($consentId !== '') { - $builder->orWhere('consent_id', $consentId); - if (in_array('abdm_consent_artifact_id', $this->db->getFieldNames('abdm_hiu_workflows') ?? [], true)) { - $builder->orWhere('abdm_consent_artifact_id', $consentId); - } - } - $builder->groupEnd(); - - $row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray(); - return ! empty($row); - } - - private function shouldAttemptHiRequest(string $requestId, string $consentId): bool - { - if (! $this->db->tableExists('abdm_hiu_workflows')) { - return true; - } - - $row = $this->latestHiRequestRow($requestId, $consentId); - if (empty($row)) { - return true; - } - - $status = strtolower(trim((string) ($row['status'] ?? ''))); - $retryable = (int) ($row['is_retryable'] ?? 0) === 1; - $retryCount = (int) ($row['retry_count'] ?? 0); - $nextRetryAt = trim((string) ($row['next_retry_at'] ?? '')); - if ($status !== 'failed' || ! $retryable) { - return true; - } - - if ($nextRetryAt === '') { - return $retryCount < 4; - } - - return strtotime($nextRetryAt) <= time(); - } - - private function latestHiRequestRow(string $requestId, string $consentId): array - { - if (! $this->db->tableExists('abdm_hiu_workflows')) { - return []; - } - - $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; - $builder = $this->db->table('abdm_hiu_workflows') - ->select('id, status, is_retryable, retry_count, next_retry_at') - ->where('operation', 'hi_request'); - - $builder->groupStart(); - $hasCondition = false; - if ($requestId !== '') { - $builder->where('request_id', $requestId); - $hasCondition = true; - } - if ($consentId !== '') { - if ($hasCondition) { - $builder->orWhere('consent_id', $consentId); - } else { - $builder->where('consent_id', $consentId); - $hasCondition = true; - } - - if (in_array('abdm_consent_artifact_id', $fields, true)) { - $builder->orWhere('abdm_consent_artifact_id', $consentId); - } - } - $builder->groupEnd(); - - if (! $hasCondition) { - return []; - } - - return $builder->orderBy('id', 'DESC')->get(1)->getRowArray() ?? []; - } - - private function generateFreshHiTransactionId(string $requestId, string $currentTransactionId): string - { - return $this->generateUuidV4(); - } - private function generateUuidV4(): string { $bytes = random_bytes(16); @@ -1574,7 +1234,7 @@ class M3HiuWorkflowService int $httpCode, bool $retryable, int $forceId = 0 - ): void { + ): int { $now = Time::now('Asia/Kolkata')->toDateTimeString(); $hmsRequestId = $this->firstNonEmptyString([ $payload['request_id'] ?? null, @@ -1706,11 +1366,12 @@ class M3HiuWorkflowService if ($id > 0) { $this->db->table('abdm_hiu_workflows')->where('id', $id)->update($row); - return; + return $id; } $row['created_at'] = $now; $this->db->table('abdm_hiu_workflows')->insert($row); + return (int) ($this->db->insertID() ?: 0); } private function isGatewayRequestIdPattern(string $value): bool diff --git a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php index 83aa0cf..7aa8828 100644 --- a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php +++ b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php @@ -13,9 +13,11 @@ class AbdmGatewayPushClient 'OPConsultRecord', 'DiagnosticReportRecord', 'DischargeSummaryRecord', + 'ImmunizationRecord', 'InvoiceRecord', 'PrescriptionRecord', 'HealthDocumentRecord', + 'WellnessRecord', ]; public function __construct(?BaseConnection $db = null) diff --git a/app/Views/abdm/hiu_documents.php b/app/Views/abdm/hiu_documents.php new file mode 100644 index 0000000..7397ed3 --- /dev/null +++ b/app/Views/abdm/hiu_documents.php @@ -0,0 +1,269 @@ +
+
+
+
ABDM Fetched Patient Documents
+ Back To HIU Console +
+
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+
Select a patient to view mapped ABDM documents.
+ +
+ + + + + + + + + + +
ActionPatientUHIDABHA Address
+
+
+
+ +
+
+
+
Document List
+
+
+ + + + + + + + + + + + +
DateTitleCare ContextDoctor
No documents loaded.
+
+
+
+
+ +
+
+
Document Detail
+
+
Select a document to view human-readable summary.
+
+
+
+
+
+ + diff --git a/app/Views/abdm/hiu_m3.php b/app/Views/abdm/hiu_m3.php index 43fb7a2..ff37e3b 100644 --- a/app/Views/abdm/hiu_m3.php +++ b/app/Views/abdm/hiu_m3.php @@ -8,7 +8,10 @@ Poll: waiting - +
+ + +
@@ -88,8 +91,7 @@ 1. Select Patient 2. Create Consent Request 3. Check Consent Status - 4. Access Consent Artifact - 5. Request Health Information + 4. Poll & Fetch Decrypted Data
@@ -113,8 +115,7 @@
- - +
@@ -124,12 +125,38 @@
- +

{}
+ +
+
+
Decrypted Data Preview
+
No data sessions yet.
+
+
+
+ + + + + + + + + + + + + + +
Txn IDConsent RefStatusRecordsCare ContextsBundle Type
Run Poll & Fetch Decrypted Data to load records.
+
+
+
@@ -177,6 +204,85 @@ + +
+
+
+
Fetched ABDM Documents (AJAX View)
+ +
+
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+
Select a patient to view mapped ABDM documents.
+
+
+ +
+ + + + + + + + + + + + +
ActionPatientUHIDABHA Address
Search patient to start.
+
+ +
+
+
+
Document List
+
+
+ + + + + + + + + + + + +
DateTitleCare ContextDoctor
No documents loaded.
+
+
+
+
+
+
+
Document Detail
+
+
Select a document to view human-readable summary.
+
+
+
+
+
+
+
@@ -219,13 +325,15 @@ var opRoute = { consent_request: '', consent_status: '', - consent_fetch: '', - hi_request: '', data_fetch: '' }; var pollSummaryUrl = ''; var patientLookupUrl = ''; + var docListUrl = ''; + var docDetailBaseUrl = ''; + var ajaxDocSelectedPatient = null; + var ajaxDocItems = []; function escHtml(v) { return (v || '').toString() @@ -275,7 +383,7 @@ var state = (consentStateText || '').trim(); var lockedCreate = hasExistingConsentFlowContext(); var policyNote = lockedCreate - ? 'Policy: reuse logged consent flow (Status -> Fetch -> HI)' + ? 'Policy: reuse logged consent flow (Status -> Data Fetch)' : 'Policy: create consent only once per active flow'; var cooldownNote = isAutoCoolingDown() ? 'Auto-next paused: ' + escHtml(autoCooldownReason || 'temporary cooldown') + '' @@ -310,13 +418,13 @@ function isValidConsentArtifactId(v) { var val = (v || '').toString().trim(); - return val !== '' && !isGatewayRequestId(val); + return val !== ''; } function hasExistingConsentFlowContext() { var consentRequestRef = getFieldValue(['consent_request_id', 'consent_id']); var consentArtifactRef = getFieldValue(['consent_artifact_id', 'consent_id']); - return (consentRequestRef !== '' && !isGatewayRequestId(consentRequestRef)) || isValidConsentArtifactId(consentArtifactRef); + return consentRequestRef !== '' || isValidConsentArtifactId(consentArtifactRef); } function canCreateConsentRequest(consentStateText) { @@ -437,7 +545,7 @@ transactionId: transactionId, abhaAddress: abhaAddress, hfrId: pickFirstNonEmpty([row && row.hfr_id, req.hfr_id]), - consentRequestId: isGatewayRequestId(consentRequestId) ? '' : consentRequestId, + consentRequestId: consentRequestId, consentArtifactId: isValidConsentArtifactId(consentArtifactIdRaw) ? consentArtifactIdRaw : '', operation: (row && row.operation ? row.operation : '').toString().toLowerCase(), workflowState: (row && row.workflow_state ? row.workflow_state : '').toString().toLowerCase(), @@ -463,14 +571,14 @@ if (wf === 'REQUESTED') { return 'REQUESTED'; } - if (wf === 'CONSENT_FETCHED') { - return 'FETCHED'; + if (wf === 'CONSENT_FETCHED' || wf === 'DATA_RECEIVED') { + return 'DATA_RECEIVED'; } if (wf === 'STATUS_CHECKED') { return 'STATUS_CHECKED'; } if (wf === 'DATA_REQUESTED' || wf === 'DATA_RECEIVED') { - return 'FETCHED'; + return 'DATA_RECEIVED'; } if ((ctx.status || '').toLowerCase() === 'failed') { return 'FAILED'; @@ -486,7 +594,7 @@ cls = 'bg-success'; } else if (['REQUESTED', 'PENDING', 'STATUS_CHECKED'].indexOf(s) !== -1) { cls = 'bg-warning text-dark'; - } else if (['FETCHED', 'CONSENT_FETCHED'].indexOf(s) !== -1) { + } else if (['FETCHED', 'CONSENT_FETCHED', 'DATA_RECEIVED'].indexOf(s) !== -1) { cls = 'bg-info text-dark'; } else if (['FAILED', 'DENIED', 'REVOKED', 'EXPIRED'].indexOf(s) !== -1) { cls = 'bg-danger'; @@ -567,10 +675,10 @@ return { op: 'consent_status', label: 'Check Consent Status', cls: 'btn-outline-primary' }; } if (status === 'GRANTED' || status === 'STATUS_CHECKED' || state === 'GRANTED' || state === 'STATUS_CHECKED') { - return { op: 'consent_fetch', label: 'Access Consent Artifact', cls: 'btn-outline-primary' }; + return { op: 'data_fetch', label: 'Poll & Fetch Decrypted Data', cls: 'btn-outline-success' }; } - if (hasArtifact && (state === 'CONSENT_FETCHED' || status === 'FETCHED')) { - return { op: 'hi_request', label: 'Request Health Information', cls: 'btn-outline-success' }; + if (hasArtifact && (state === 'CONSENT_FETCHED' || state === 'DATA_RECEIVED' || status === 'FETCHED' || status === 'DATA_RECEIVED')) { + return { op: 'data_fetch', label: 'Poll & Fetch Decrypted Data', cls: 'btn-outline-success' }; } if (hasArtifact) { @@ -659,19 +767,18 @@ var hasArtifact = !!(latest && latest.consent_id); var selectedAbha = (selectedPatient.abha_address || '').toString().trim(); - var hasHiRequest = (selectedItems || []).some(function (row) { + var hasDataFetch = (selectedItems || []).some(function (row) { var ctx = extractWorkflowContext(row); var op = (row.operation || '').toString().toLowerCase(); var status = (row.status || '').toString().toLowerCase(); - return ctx.abhaAddress === selectedAbha && op === 'hi_request' && status === 'success'; + return ctx.abhaAddress === selectedAbha && op === 'data_fetch' && status === 'success'; }); var done = { 1: true, 2: hasConsentRequest, 3: hasStatusChecked, - 4: hasArtifact, - 5: hasHiRequest + 4: hasDataFetch }; itemsHost.querySelectorAll('span[data-step]').forEach(function (el) { @@ -725,8 +832,7 @@ actions.push(''); } if (canFetch) { - actions.push(''); - actions.push(''); + actions.push(''); } var tr = document.createElement('tr'); @@ -770,8 +876,8 @@ if (ctx.lastError.indexOf('consent record not found') !== -1 && hasStatusRef) { return { op: 'consent_status', label: 'Check Consent Status' }; } - if (ctx.operation === 'hi_request' && hasArtifact) { - return { op: 'hi_request', label: 'Retry Request HI' }; + if (ctx.operation === 'data_fetch' && hasArtifact) { + return { op: 'data_fetch', label: 'Retry Data Fetch' }; } } @@ -783,20 +889,16 @@ if (ctx.workflowState === 'requested' && hasStatusRef) { return { op: 'consent_status', label: 'Check Consent Status' }; } - if ((ctx.workflowState === 'granted' || ctx.workflowState === 'approved' || ctx.workflowState === 'status_checked') && hasArtifact) { - return { op: 'consent_fetch', label: 'Fetch Consent Artifact' }; + if ((ctx.workflowState === 'granted' || ctx.workflowState === 'approved' || ctx.workflowState === 'status_checked') && hasStatusRef) { + return { op: 'data_fetch', label: 'Fetch Decrypted Data' }; } if (hasStatusRef) { return { op: 'consent_status', label: 'Check Consent Status' }; } } - if (ctx.operation === 'consent_fetch' && hasArtifact) { - return { op: 'hi_request', label: 'Request Health Information' }; - } - - if (ctx.operation === 'hi_request' && hasArtifact) { - return { op: 'hi_request', label: 'Request Health Information' }; + if ((ctx.operation === 'consent_fetch' || ctx.operation === 'data_fetch') && hasArtifact) { + return { op: 'data_fetch', label: 'Fetch Decrypted Data' }; } return null; @@ -827,7 +929,7 @@ if (status === 'failed') { if (error.indexOf('consentid is required') !== -1) { - return { level: 'warning', message: 'Consent artifact id missing. Run Check Consent Status until consent is GRANTED, then Fetch Consent Artifact.' }; + return { level: 'warning', message: 'Consent id missing. Run Check Consent Status until consent is GRANTED, then fetch decrypted data.' }; } if (error.indexOf('consent record not found') !== -1) { return { level: 'warning', message: 'Consent not found on gateway. Verify request_id/consentRequestId and run Check Consent Status again.' }; @@ -846,17 +948,14 @@ return { level: 'info', message: 'Consent still REQUESTED. Wait for patient approval in ABDM app, then Check Consent Status again.' }; } if ((state === 'granted' || state === 'approved' || state === 'status_checked') && hasArtifact) { - return { level: 'success', message: 'Consent seems ready. Next step: ' + (nextAction ? nextAction.label : 'Fetch Consent Artifact') + ', then Request Health Information.' }; + return { level: 'success', message: 'Consent seems ready. Next step: ' + (nextAction ? nextAction.label : 'Fetch Decrypted Data') + '.' }; } if (state === 'granted' || state === 'approved' || state === 'status_checked') { - return { level: 'info', message: 'Consent state is ready, but artifact id is missing in this row. Use Latest Log Context and retry Fetch.' }; + return { level: 'info', message: 'Consent state is ready. Use Latest Log Context and run data fetch with consent id.' }; } } - if (operation === 'consent_fetch') { - return { level: 'success', message: 'Consent artifact fetched. Next step: Request Health Information.' }; - } - if (operation === 'hi_request') { - return { level: 'success', message: 'Health information request sent. Next step: monitor timeline/poll summary for data updates.' }; + if (operation === 'consent_fetch' || operation === 'data_fetch') { + return { level: 'success', message: 'Data polling executed. Monitor timeline/poll summary for decrypted records.' }; } return { level: 'muted', message: 'Use this row context and continue with the next HIU operation.' }; @@ -964,14 +1063,13 @@ var hasPatient = !!selectedPatient; var consentRequestRef = getFieldValue(['consent_request_id', 'consent_id']); var consentArtifactRef = getFieldValue(['consent_artifact_id', 'consent_id']); - var hasConsentRequestRef = (consentRequestRef !== '' && !isGatewayRequestId(consentRequestRef)); - var hasConsentArtifactRef = (consentArtifactRef !== '' && !isGatewayRequestId(consentArtifactRef)); + var hasConsentRequestRef = (consentRequestRef !== ''); + var hasConsentArtifactRef = (consentArtifactRef !== ''); var canFetchByState = isConsentApproved(consentStateText) || isTerminalConsentState(consentStateText); document.getElementById('btnConsentRequest').disabled = !canCreateConsentRequest(consentStateText); document.getElementById('btnConsentStatus').disabled = !hasConsentRequestRef; - document.getElementById('btnConsentFetch').disabled = !(hasConsentArtifactRef && isSuccessConsentState(consentStateText) && canFetchByState); - document.getElementById('btnHiRequest').disabled = !(hasConsentArtifactRef && isConsentApproved(consentStateText)); + document.getElementById('btnConsentFetch').disabled = !((hasConsentArtifactRef || hasConsentRequestRef) && isSuccessConsentState(consentStateText) && canFetchByState); updateReadinessBox(consentStateText); } @@ -986,7 +1084,7 @@ var latest = p.latest_consent || {}; var consentRequestRef = (latest.abdm_consent_request_id || '').toString(); var consentArtifactRef = (latest.abdm_consent_artifact_id || latest.consent_id || '').toString(); - if (consentRequestRef && !isGatewayRequestId(consentRequestRef)) { + if (consentRequestRef) { setFieldValue(['consent_request_id', 'consent_id'], consentRequestRef); } if (isValidConsentArtifactId(consentArtifactRef)) { @@ -1088,6 +1186,43 @@ return /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test((v || '').toString().trim()); } + function normalizeConsentHiTypes(hiTypes) { + var arr = Array.isArray(hiTypes) ? hiTypes : (hiTypes ? [hiTypes] : []); + var map = { + opconsultrecord: 'OPConsultation', + opconsultation: 'OPConsultation', + diagnosticreportrecord: 'DiagnosticReport', + diagnosticreport: 'DiagnosticReport', + prescriptionrecord: 'Prescription', + prescription: 'Prescription', + dischargesummaryrecord: 'DischargeSummary', + dischargesummary: 'DischargeSummary', + invoice: 'Invoice', + invoicerecord: 'Invoice', + healthdocumentrecord: 'HealthDocument', + healthdocument: 'HealthDocument', + immunization: 'ImmunizationRecord', + immunizationrecord: 'ImmunizationRecord', + wellness: 'Wellness', + wellnessrecord: 'Wellness' + }; + + var out = []; + arr.forEach(function (item) { + var raw = (item || '').toString().trim(); + if (!raw) { + return; + } + var key = raw.toLowerCase(); + var resolved = map[key] || raw; + if (out.indexOf(resolved) === -1) { + out.push(resolved); + } + }); + + return out; + } + function buildGatewayPayload(op, raw) { var requestId = (raw.requestId || raw.request_id || '').toString().trim(); var timestamp = (raw.timestamp || '').toString().trim(); @@ -1141,6 +1276,11 @@ throw new Error('Patient ABHA address must be in format like 91510165305101@sbx.'); } + consent.hiTypes = normalizeConsentHiTypes(consent.hiTypes || []); + if (!consent.hiTypes.length) { + throw new Error('consent.hiTypes is required for consent request.'); + } + base.consent = consent; return base; } @@ -1157,73 +1297,27 @@ if (requestIdRef) { base.request_id = requestIdRef; } - if (consentRequestId && !isGatewayRequestId(consentRequestId)) { + if (consentRequestId) { base.consent_request_id = consentRequestId; } - if (consentIdRef && !isGatewayRequestId(consentIdRef)) { + if (consentIdRef) { base.consent_id = consentIdRef; } return base; } - if (op === 'consent_fetch') { - var consentId = (raw.abdm_consent_artifact_id || raw.consentId || '').toString().trim(); + if (op === 'data_fetch') { + var consentIdFetch = (raw.abdm_consent_artifact_id || raw.consentId || raw.consent_id || '').toString().trim(); + var consentRequestRefFetch = (raw.abdm_consent_request_id || raw.consentRequestId || raw.consent_request_id || '').toString().trim(); + + // Gateway accepts original consent request id in consent_id for NAT polling. var requestIdRefFetch = (raw.request_id || raw.requestId || '').toString().trim(); - var consentRequestIdFetch = (raw.abdm_consent_request_id || raw.consentRequestId || '').toString().trim(); - - if (consentId && isGatewayRequestId(consentId)) { - throw new Error('mapping_error: consentId looks like gateway/local request_id (REQ-...). Use ABDM consent artifact id only.'); + var consentRef = consentIdFetch || consentRequestRefFetch || requestIdRefFetch; + if (!consentRef) { + throw new Error('consent_id is required for decrypted data fetch. Run status polling until consent is GRANTED.'); } - if (consentId) { - base.consentId = consentId; - } - if (requestIdRefFetch) { - base.request_id = requestIdRefFetch; - } - if (consentRequestIdFetch && !isGatewayRequestId(consentRequestIdFetch)) { - base.consent_request_id = consentRequestIdFetch; - } - - if (!base.consentId && !base.request_id && !base.consent_request_id) { - throw new Error('No fetch context found. Select a consent row first, then click Access Consent Artifact.'); - } - return base; - } - - if (op === 'hi_request') { - var hiRequest = raw.hiRequest; - if (!hiRequest || typeof hiRequest !== 'object' || Array.isArray(hiRequest)) { - hiRequest = { - consent: { - id: (raw.consentId || raw.consent_id || '').toString().trim() - }, - dateRange: { - from: new Date(Date.now() - (365 * 24 * 60 * 60 * 1000)).toISOString(), - to: new Date().toISOString() - }, - dataPushUrl: 'https://abdm-bridge.e-atria.in/api/v3/hiu/data/push', - transactionId: (raw.transactionId || raw.transaction_id || '').toString().trim() || genReq('TXN-HIU') - }; - } - - if (!hiRequest.consent || typeof hiRequest.consent !== 'object') { - hiRequest.consent = {}; - } - if (!hiRequest.consent.id) { - hiRequest.consent.id = (raw.consentId || raw.consent_id || '').toString().trim(); - } - if (!(hiRequest.consent.id || '').toString().trim()) { - throw new Error('hiRequest.consent.id is required for health information request.'); - } - - if (!hiRequest.transactionId) { - hiRequest.transactionId = (raw.transactionId || raw.transaction_id || '').toString().trim() || genReq('TXN-HIU'); - } - setFieldValue(['transaction_id'], hiRequest.transactionId); - - base.hiRequest = hiRequest; - base.transactionId = hiRequest.transactionId; + base.consent_id = consentRef; return base; } @@ -1231,9 +1325,92 @@ } function setResult(obj) { + renderDataPreview(obj); document.getElementById('result_box').textContent = JSON.stringify(obj, null, 2); } + function parseMaybeJson(raw) { + if (raw && typeof raw === 'object') { + return raw; + } + var txt = (raw || '').toString().trim(); + if (!txt) { + return null; + } + try { + return JSON.parse(txt); + } catch (e) { + return null; + } + } + + function extractSessionsFromResponse(res) { + var data = (res && typeof res === 'object') ? (res.data || {}) : {}; + var sessions = []; + + if (Array.isArray(data.sessions)) { + sessions = data.sessions; + } else if (Array.isArray(res.sessions)) { + sessions = res.sessions; + } + + return sessions; + } + + function summarizeBundleType(decryptedItem) { + var parsed = parseMaybeJson((decryptedItem || {}).decrypted_data); + if (!parsed || typeof parsed !== 'object') { + return '-'; + } + var bundleType = (parsed.type || '').toString().trim(); + var resourceType = (parsed.resourceType || '').toString().trim(); + if (resourceType && bundleType) { + return resourceType + ' / ' + bundleType; + } + return resourceType || bundleType || '-'; + } + + function renderDataPreview(res) { + var card = document.getElementById('data_preview_card'); + var meta = document.getElementById('data_preview_meta'); + var tbody = document.querySelector('#data_preview_table tbody'); + if (!card || !meta || !tbody) { + return; + } + + var sessions = extractSessionsFromResponse(res); + if (!sessions.length) { + card.classList.add('d-none'); + return; + } + + card.classList.remove('d-none'); + var totalRecords = 0; + sessions.forEach(function (s) { + var items = Array.isArray(s.decrypted_data) ? s.decrypted_data : []; + totalRecords += items.length; + }); + meta.textContent = 'Sessions: ' + sessions.length + ' | Records: ' + totalRecords; + + tbody.innerHTML = ''; + sessions.forEach(function (session) { + var records = Array.isArray(session.decrypted_data) ? session.decrypted_data : []; + var contexts = records.map(function (r) { + return (r.careContextReference || '').toString().trim(); + }).filter(function (v) { return v !== ''; }); + + var tr = document.createElement('tr'); + tr.innerHTML = '' + + '' + escHtml((session.transaction_id || '-').toString()) + '' + + '' + escHtml((session.consent_id || '-').toString()) + '' + + '' + escHtml((session.status || '-').toString()) + '' + + '' + records.length + '' + + '' + escHtml((contexts.slice(0, 3).join(', ') || '-').toString()) + '' + + '' + escHtml(summarizeBundleType(records[0] || {})) + ''; + tbody.appendChild(tr); + }); + } + function postJson(url, body) { return fetch(url, { method: 'POST', @@ -1247,6 +1424,238 @@ }); } + function parseDisplayDate(v) { + var txt = (v || '').toString().trim(); + if (!txt) { + return '-'; + } + var d = new Date(txt.replace(' ', 'T')); + if (isNaN(d.getTime())) { + return txt; + } + return d.toLocaleString(); + } + + function openAjaxDocsPanel() { + var panel = document.getElementById('ajax_docs_panel'); + if (!panel) { + return; + } + panel.classList.remove('d-none'); + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (!ajaxDocSelectedPatient) { + ajaxDocsSearchPatients(); + } + } + + function closeAjaxDocsPanel() { + var panel = document.getElementById('ajax_docs_panel'); + if (!panel) { + return; + } + panel.classList.add('d-none'); + } + + function ajaxDocsRenderPatients(items) { + var tbody = document.querySelector('#ajax_doc_patient_table tbody'); + if (!tbody) { + return; + } + tbody.innerHTML = ''; + if (!items.length) { + tbody.innerHTML = 'No patients found.'; + return; + } + + items.forEach(function (p, idx) { + var tr = document.createElement('tr'); + tr.innerHTML = '' + + '' + + '' + escHtml(p.patient_name || '') + '' + + '' + escHtml(p.patient_code || '') + '' + + '' + escHtml(p.abha_address || '') + ''; + tbody.appendChild(tr); + }); + + tbody.querySelectorAll('button[data-ajax-doc-patient-idx]').forEach(function (btn) { + btn.addEventListener('click', function () { + var idx = parseInt(btn.getAttribute('data-ajax-doc-patient-idx'), 10); + if (Number.isNaN(idx) || !items[idx]) { + return; + } + ajaxDocSelectedPatient = items[idx]; + var status = document.getElementById('ajax_doc_patient_status'); + if (status) { + status.textContent = 'Selected: ' + (ajaxDocSelectedPatient.patient_name || '-') + ' | ABHA: ' + (ajaxDocSelectedPatient.abha_address || '-'); + } + ajaxDocsLoadDocuments(); + }); + }); + } + + function ajaxDocsSearchPatients() { + var q = getFieldValue(['ajax_doc_patient_search']); + fetch(patientLookupUrl + '?q=' + encodeURIComponent(q), { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if ((res.ok || 0) !== 1) { + var status = document.getElementById('ajax_doc_patient_status'); + if (status) { + status.textContent = res.error || 'Patient search failed'; + } + return; + } + ajaxDocsRenderPatients(Array.isArray(res.items) ? res.items : []); + var s = document.getElementById('ajax_doc_patient_status'); + if (s) { + s.textContent = (res.count || 0) + ' patient(s) found'; + } + }) + .catch(function (e) { + var status = document.getElementById('ajax_doc_patient_status'); + if (status) { + status.textContent = e.message || 'Patient search failed'; + } + }); + } + + function ajaxDocsRenderList(items) { + ajaxDocItems = items || []; + var tbody = document.querySelector('#ajax_doc_table tbody'); + if (!tbody) { + return; + } + tbody.innerHTML = ''; + + if (!ajaxDocItems.length) { + tbody.innerHTML = 'No documents loaded.'; + var box = document.getElementById('ajax_doc_detail_box'); + if (box) { + box.innerHTML = '
No documents found for selected patient.
'; + } + return; + } + + ajaxDocItems.forEach(function (doc, idx) { + var tr = document.createElement('tr'); + tr.style.cursor = 'pointer'; + tr.setAttribute('data-ajax-doc-idx', idx); + tr.innerHTML = '' + + '' + escHtml(parseDisplayDate(doc.document_date || doc.created_at)) + '' + + '' + escHtml(doc.document_title || '-') + '' + + '' + escHtml(doc.care_context_reference || '-') + '' + + '' + escHtml(doc.practitioner_name || '-') + ''; + tbody.appendChild(tr); + }); + + tbody.querySelectorAll('tr[data-ajax-doc-idx]').forEach(function (row) { + row.addEventListener('click', function () { + var idx = parseInt(row.getAttribute('data-ajax-doc-idx'), 10); + if (Number.isNaN(idx) || !ajaxDocItems[idx]) { + return; + } + ajaxDocsLoadDetail((ajaxDocItems[idx].id || 0)); + }); + }); + + ajaxDocsLoadDetail((ajaxDocItems[0].id || 0)); + } + + function ajaxDocsLoadDocuments() { + if (!ajaxDocSelectedPatient) { + setResult({ ok: 0, message: 'Select a patient first in fetched documents panel.' }); + return; + } + + var q = getFieldValue(['ajax_doc_search']); + var url = docListUrl + + '?patient_id=' + encodeURIComponent(ajaxDocSelectedPatient.patient_id || 0) + + '&abha_address=' + encodeURIComponent(ajaxDocSelectedPatient.abha_address || '') + + '&q=' + encodeURIComponent(q); + + fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if ((res.ok || 0) !== 1) { + ajaxDocsRenderList([]); + return; + } + ajaxDocsRenderList(Array.isArray(res.items) ? res.items : []); + }) + .catch(function () { + ajaxDocsRenderList([]); + }); + } + + function ajaxDocsRenderBulletList(title, rows, formatter) { + var list = Array.isArray(rows) ? rows : []; + if (!list.length) { + return '
' + escHtml(title) + '
No data
'; + } + var html = '
' + escHtml(title) + '
    '; + list.forEach(function (row) { + html += '
  • ' + formatter(row) + '
  • '; + }); + html += '
'; + return html; + } + + function ajaxDocsLoadDetail(id) { + var box = document.getElementById('ajax_doc_detail_box'); + if (!box) { + return; + } + if (!id) { + box.innerHTML = '
Select a document to view detail.
'; + return; + } + + box.innerHTML = '
Loading...
'; + fetch(docDetailBaseUrl + '/' + encodeURIComponent(id), { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if ((res.ok || 0) !== 1) { + box.innerHTML = '
Unable to load detail.
'; + return; + } + + var item = res.item || {}; + var s = item.summary || {}; + var html = '' + + '
' + + '
Patient: ' + escHtml(s.patient_name || item.patient_name || '-') + '
' + + '
ABHA: ' + escHtml(item.abha_address || '-') + '
' + + '
Document: ' + escHtml(item.document_title || '-') + '
' + + '
Date: ' + escHtml(parseDisplayDate(item.document_date || '')) + '
' + + '
Doctor: ' + escHtml(item.practitioner_name || '-') + '
' + + '
Organization: ' + escHtml(item.organization_name || '-') + '
' + + '
Care Context: ' + escHtml(item.care_context_reference || '-') + '
' + + '
Bundle: ' + escHtml(item.bundle_type || '-') + '
' + + '
'; + + html += ajaxDocsRenderBulletList('Diagnoses', s.conditions || [], function (row) { + return escHtml((row && row.text) || '-'); + }); + html += ajaxDocsRenderBulletList('Vitals', s.vitals || [], function (row) { + return escHtml(((row && row.name) || '-') + ': ' + ((row && row.value) || '-')); + }); + html += ajaxDocsRenderBulletList('Medications', s.medications || [], function (row) { + var name = (row && row.name) || '-'; + var dose = (row && row.dose) || ''; + return escHtml(name + (dose ? (' | ' + dose) : '')); + }); + + box.innerHTML = html; + }) + .catch(function (e) { + box.innerHTML = '
' + escHtml(e.message || 'Unable to load detail') + '
'; + }); + } + function run(op, options) { options = options || {}; var url = opRoute[op]; @@ -1278,7 +1687,7 @@ if ((res.ok || 0) === 1) { var data = res.data || {}; var consentReqRef = (data.abdm_consent_request_id || data.consentRequestId || data.consent_request_id || '').toString(); - if (consentReqRef && !isGatewayRequestId(consentReqRef)) { + if (consentReqRef) { setFieldValue(['consent_request_id', 'consent_id'], consentReqRef); } @@ -1294,37 +1703,32 @@ startConsentPolling(); } - // Ordered workflow automation: status success -> fetch artifact, then fetch success -> request HI. - if (op === 'consent_status' && isSuccessConsentState(state) && isValidConsentArtifactId(consentArtifactRef)) { + // Ordered workflow automation: status success -> data fetch polling. + if (op === 'consent_status' && isSuccessConsentState(state) && (isValidConsentArtifactId(consentArtifactRef) || consentReqRef)) { if (isAutoNextStepEnabled() && !isAutoCoolingDown()) { - run('consent_fetch', { silent: true }); + run('data_fetch', { silent: true }); } } - if (op === 'consent_fetch' && isConsentApproved(state) && isValidConsentArtifactId(consentArtifactRef)) { + if (op === 'data_fetch' && isConsentApproved(state) && isValidConsentArtifactId(consentArtifactRef)) { // Always stop status polling once artifact fetch succeeds. stopConsentPolling(); - if (isAutoNextStepEnabled() && !isAutoCoolingDown()) { - run('hi_request', { silent: true }); - } - } - if (op === 'hi_request') { clearAutoCooldown(); } - if ((op === 'consent_status' || op === 'consent_fetch') && isTerminalConsentState(state)) { + if ((op === 'consent_status' || op === 'data_fetch') && isTerminalConsentState(state)) { stopConsentPolling(); } - } else if (op === 'consent_status' || op === 'consent_fetch') { + } else if (op === 'consent_status' || op === 'data_fetch') { var errState = resolveConsentStateFromResponse(res, res.data || {}); var errText = (res.error || ((res.data || {}).error_text) || '').toString().toLowerCase(); if (isTerminalConsentState(errState) || errText.indexOf('mapping_error') !== -1) { stopConsentPolling(); } - } else if (op === 'hi_request') { - var hiErrText = (res.error || ((res.data || {}).error_text) || '').toString().toLowerCase(); - var hiCode = parseInt(((res.data || {}).http_code || res.http_code || 0), 10); - if (hiCode === 403 || hiErrText.indexOf('cloudfront') !== -1 || hiErrText.indexOf('request blocked') !== -1) { - setAutoCooldown(180000, 'HI request blocked (403). Retry after cooldown or after gateway unblocks endpoint.'); - syncButtons(); + if (op === 'data_fetch') { + var dataCode = parseInt(((res.data || {}).http_code || res.http_code || 0), 10); + if (dataCode === 403 || errText.indexOf('cloudfront') !== -1 || errText.indexOf('request blocked') !== -1) { + setAutoCooldown(180000, 'Data fetch blocked (403). Retry after cooldown or after gateway unblocks endpoint.'); + syncButtons(); + } } } @@ -1531,6 +1935,40 @@ if (btnRefreshTimeline) { btnRefreshTimeline.addEventListener('click', fetchTimeline); } + var btnOpenDocumentsAjax = document.getElementById('btnOpenDocumentsAjax'); + if (btnOpenDocumentsAjax) { + btnOpenDocumentsAjax.addEventListener('click', openAjaxDocsPanel); + } + var btnCloseAjaxDocs = document.getElementById('btnCloseAjaxDocs'); + if (btnCloseAjaxDocs) { + btnCloseAjaxDocs.addEventListener('click', closeAjaxDocsPanel); + } + var btnAjaxDocPatientSearch = document.getElementById('btnAjaxDocPatientSearch'); + if (btnAjaxDocPatientSearch) { + btnAjaxDocPatientSearch.addEventListener('click', ajaxDocsSearchPatients); + } + var btnAjaxDocSearch = document.getElementById('btnAjaxDocSearch'); + if (btnAjaxDocSearch) { + btnAjaxDocSearch.addEventListener('click', ajaxDocsLoadDocuments); + } + var ajaxDocPatientSearch = document.getElementById('ajax_doc_patient_search'); + if (ajaxDocPatientSearch) { + ajaxDocPatientSearch.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + ajaxDocsSearchPatients(); + } + }); + } + var ajaxDocSearch = document.getElementById('ajax_doc_search'); + if (ajaxDocSearch) { + ajaxDocSearch.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + ajaxDocsLoadDocuments(); + } + }); + } var btnUseLatestLog = document.getElementById('btnUseLatestLog'); if (btnUseLatestLog) { btnUseLatestLog.addEventListener('click', function () { diff --git a/app/Views/billing/Patient_Profile_Opd_V.php b/app/Views/billing/Patient_Profile_Opd_V.php index a404e09..73a1416 100644 --- a/app/Views/billing/Patient_Profile_Opd_V.php +++ b/app/Views/billing/Patient_Profile_Opd_V.php @@ -6,6 +6,18 @@ $backTitle = trim((string) ($backTitle ?? 'Profile')); if ($backUrl === '') { $backUrl = base_url('billing/patient/person_record') . '/' . (int) ($patient->id ?? 0) . '/0'; } +$patientAbhaId = trim((string) ($patient->abha_id ?? $patient->abha_no ?? $patient->abha ?? '')); +$patientAbhaAddress = trim((string) ($patient->abha_address ?? '')); +if ($patientAbhaAddress === '' && $patientAbhaId !== '' && strpos($patientAbhaId, '@') !== false) { + $patientAbhaAddress = $patientAbhaId; +} +if ($patientAbhaAddress === '' && preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', (string) ($patient->log ?? ''), $abhaLogMatch) === 1) { + $patientAbhaAddress = trim((string) ($abhaLogMatch[1] ?? '')); +} +$abhaVerifiedStatus = strtoupper(trim((string) ($patient->abha_verified_status ?? ''))); +$abhaKycVerified = (int) ($patient->abha_kyc_verified ?? 0) === 1; +$abhaMobileVerified = (int) ($patient->abha_mobile_verified ?? 0) === 1; +$abhaIsVerified = $abhaVerifiedStatus === 'VERIFIED' || ($abhaKycVerified && $abhaMobileVerified); ?>
@@ -30,6 +42,18 @@ if ($backUrl === '') {

Patient: p_fname ?? '') ?>

+ + +
+
+ @@ -174,6 +220,75 @@ if ($backUrl === '') {
+ +
+ +
+
+
+
ABHA Context
+
+ ABHA ID: +  |  + ABHA Address: +  |  + Status: + + VERIFIED + + + +
+
+
+ + +
+
+ +
Click "Refresh Fetched Data" to load ABDM records mapped to this patient.
+ +
+
+ 1. Requested + 2. Granted + 3. Fetched +
+
+
+
+
+ +
+
+
+ + +
+
+ + + + + + + + + + + +
DateTitleCare Context
No records loaded.
+
+
+
+
+
Select a fetched document to view details.
+
+
+
+ +
+
@@ -199,6 +314,16 @@ if ($backUrl === '') {