diff --git a/app/Config/Routes.php b/app/Config/Routes.php index aefcc28..ef69735 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -624,6 +624,10 @@ $routes->post('AbdmGateway/scan_share_link_patient/(:num)', 'AbdmGateway::scanSh $routes->post('AbdmGateway/scan_share_lookup_result_callback', 'AbdmGateway::scanShareLookupResultCallback'); $routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback'); +$routes->post('AbdmGateway/hip_consent_notify_callback', 'AbdmGateway::hipConsentNotifyCallback'); +$routes->post('api/v3/hiu/consent/on-notify', 'AbdmGateway::hipConsentNotifyCallback'); +$routes->post('api/v3/hiu/consent/on_notify', 'AbdmGateway::hipConsentNotifyCallback'); +$routes->post('api/v3/hiu/consent/notify', 'AbdmGateway::hipConsentNotifyCallback'); $routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevokedCallback'); $routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback'); // ABDM M3 HIU (HMS -> Gateway) @@ -637,9 +641,17 @@ $routes->post('AbdmHiu/consent_request_fetch', 'AbdmHiu::consentRequestFetch', [ $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'); +$routes->post('AbdmHiu/health_information_on_request_callback', 'AbdmHiu::healthInformationOnRequestWebhook'); +$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]); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index b948f5f..3420b8d 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -670,6 +670,121 @@ class AbdmGateway extends BaseController ]); } + // ========================================================================= + // HIP Consent Notify Callback — gateway forwards consent notify updates. + // POST /AbdmGateway/hip_consent_notify_callback (no auth filter — public webhook) + // ========================================================================= + + public function hipConsentNotifyCallback() + { + $signatureFailure = $this->validateWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload) || empty($payload)) { + return $this->response->setStatusCode(400)->setJSON([ + 'ok' => 0, + 'error_text' => 'Invalid JSON payload', + ]); + } + + $consentObj = []; + if (is_array($payload['consent'] ?? null)) { + $consentObj = (array) $payload['consent']; + } elseif (is_array($payload['consentDetail'] ?? null)) { + $consentObj = (array) $payload['consentDetail']; + } + + $requestId = trim((string) ( + $payload['request_id'] + ?? $payload['requestId'] + ?? ($payload['response']['requestId'] ?? '') + ?? '' + )); + $consentId = trim((string) ( + $consentObj['id'] + ?? $payload['consent_id'] + ?? $payload['consentId'] + ?? '' + )); + $abhaAddress = trim((string) ( + $consentObj['patient']['id'] + ?? $payload['abha_address'] + ?? $payload['abha_id'] + ?? '' + )); + $statusRaw = trim((string) ( + $consentObj['status'] + ?? $payload['consent_status'] + ?? $payload['status'] + ?? 'status_checked' + )); + $status = strtolower($statusRaw); + + // Keep existing M3 workflow in sync for dateRange and consent status lookups. + try { + $m3Service = new \App\Libraries\Abdm\M3HiuWorkflowService(); + $m3Service->ingestConsentUpdateWebhook($payload); + } catch (\Throwable $e) { + // Fail-open: callback must still ACK to avoid upstream retry storms. + } + + if ($this->db->tableExists('consent_logs')) { + $logStatus = match ($status) { + 'granted', 'approved', 'active' => 'GRANTED', + 'revoked', 'denied' => 'REVOKED', + 'expired' => 'EXPIRED', + default => 'GRANTED', + }; + + $dateRange = $consentObj['permission']['dateRange'] ?? []; + $expiresAt = trim((string) ($dateRange['to'] ?? $payload['expires_at'] ?? '')); + + $this->db->table('consent_logs')->insert([ + 'patient_id' => (int) ($payload['patient_id'] ?? 0), + 'abha_id' => $abhaAddress !== '' ? $abhaAddress : null, + 'consent_id' => $consentId !== '' ? $consentId : ($requestId !== '' ? $requestId : null), + 'purpose' => trim((string) ($consentObj['purpose']['text'] ?? '')) ?: null, + 'status' => $logStatus, + 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + 'expires_at' => $expiresAt !== '' ? $expiresAt : null, + ]); + } + + if ($this->db->tableExists('abdm_api_logs')) { + $this->db->table('abdm_api_logs')->insert([ + 'channel' => 'bridge', + 'event_type' => 'abdm.hip.consent.notify.callback', + 'endpoint' => '/AbdmGateway/hip_consent_notify_callback', + 'http_method' => 'POST', + 'entity_type' => 'consent', + 'entity_id' => $consentId !== '' ? $consentId : $requestId, + 'request_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'response_code' => 202, + 'response_json' => (string) json_encode([ + 'ok' => 1, + 'status' => 'accepted', + 'request_id' => $requestId, + 'consent_id' => $consentId, + 'consent_status' => $statusRaw, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'status' => 'success', + 'error_message' => null, + 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } + + return $this->response->setStatusCode(202)->setJSON([ + 'ok' => 1, + 'status' => 'accepted', + 'request_id' => $requestId, + 'consent_id' => $consentId !== '' ? $consentId : null, + 'consent_status' => $statusRaw, + ]); + } + public function scanShareLookup() { if (! $this->request->isAJAX()) { diff --git a/app/Controllers/AbdmHiu.php b/app/Controllers/AbdmHiu.php index fa68bc2..259f443 100644 --- a/app/Controllers/AbdmHiu.php +++ b/app/Controllers/AbdmHiu.php @@ -53,6 +53,75 @@ class AbdmHiu extends BaseController return $this->handleOperation('data_fetch', 'DATA_PENDING'); } + public function consentUpdateWebhook() + { + $signatureFailure = $this->validateGatewayWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload) || empty($payload)) { + return $this->response->setStatusCode(400)->setJSON([ + 'ok' => 0, + 'error' => 'Invalid consent callback payload', + ]); + } + + $result = $this->service->ingestConsentUpdateWebhook($payload); + $httpCode = (int) ($result['ok'] ?? 0) === 1 ? 202 : 400; + + return $this->response->setStatusCode($httpCode)->setJSON($result); + } + + public function healthInformationOnRequestWebhook() + { + $signatureFailure = $this->validateGatewayWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload) || empty($payload)) { + return $this->response->setStatusCode(400)->setJSON([ + 'ok' => 0, + 'error' => 'Invalid health-information callback payload', + ]); + } + + $result = $this->service->ingestHealthInformationCallback($payload, 'hi_on_request_callback'); + + return $this->response->setStatusCode(202)->setJSON([ + 'ok' => 1, + 'status' => 'accepted', + 'result' => $result, + ]); + } + + public function healthInformationDataPushWebhook() + { + $signatureFailure = $this->validateGatewayWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload) || empty($payload)) { + return $this->response->setStatusCode(400)->setJSON([ + 'ok' => 0, + 'error' => 'Invalid data push payload', + ]); + } + + $result = $this->service->ingestHealthInformationCallback($payload, 'hi_data_push_callback'); + + return $this->response->setStatusCode(202)->setJSON([ + 'ok' => 1, + 'status' => 'accepted', + 'result' => $result, + ]); + } + public function timeline() { $filters = [ @@ -328,4 +397,59 @@ class AbdmHiu extends BaseController { return preg_match('/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/', trim($value)) === 1; } + + private function validateGatewayWebhookSignature(): ?\CodeIgniter\HTTP\ResponseInterface + { + $secret = trim((string) ( + $this->readRuntimeSetting('GATEWAY_TO_HMS_HMAC_SECRET') + ?: $this->readRuntimeSetting('EKA_WEBHOOK_SECRET') + ?: $this->readRuntimeSetting('HMS_WEBHOOK_SECRET') + )); + + // If no secret is configured, accept payload to keep callback flow functional. + if ($secret === '') { + return null; + } + + $rawBody = (string) $this->request->getBody(); + $provided = trim((string) ( + $this->request->getHeaderLine('X-Signature') + ?: $this->request->getHeaderLine('X-Hub-Signature-256') + )); + + if ($provided === '') { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error' => 'Missing webhook signature', + ]); + } + + $expected = hash_hmac('sha256', $rawBody, $secret); + $normalizedProvided = preg_replace('/^sha256=/i', '', $provided) ?? ''; + + if (! hash_equals($expected, $normalizedProvided)) { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error' => 'Invalid webhook signature', + ]); + } + + return null; + } + + private function readRuntimeSetting(string $name): string + { + if (! $this->db->tableExists('hospital_setting')) { + return ''; + } + + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->where('s_name', $name) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + return trim((string) ($row['s_value'] ?? '')); + } } diff --git a/app/Controllers/Opd.php b/app/Controllers/Opd.php index e90db23..5e94613 100644 --- a/app/Controllers/Opd.php +++ b/app/Controllers/Opd.php @@ -1660,15 +1660,6 @@ class Opd extends BaseController ]); } - if ($abhaAddress !== '' && ! $this->isValidAbhaAddress($abhaAddress)) { - return $this->response->setJSON([ - 'insertid' => 0, - 'error_text' => 'Invalid ABHA Address format.', - 'csrfName' => csrf_token(), - 'csrfHash' => csrf_hash(), - ]); - } - $sql = "select * from patient_master where id=" . $pid; $query = $this->db->query($sql); $personInfo = $query->getResult(); diff --git a/app/Database/Migrations/2026-07-09-000714_AdjustAbdmHiuWorkflowRequestOperationIndex.php b/app/Database/Migrations/2026-07-09-000714_AdjustAbdmHiuWorkflowRequestOperationIndex.php new file mode 100644 index 0000000..5ea1722 --- /dev/null +++ b/app/Database/Migrations/2026-07-09-000714_AdjustAbdmHiuWorkflowRequestOperationIndex.php @@ -0,0 +1,66 @@ +db->tableExists('abdm_hiu_workflows')) { + return; + } + + $indexes = $this->db->query('SHOW INDEX FROM abdm_hiu_workflows')->getResultArray(); + $hasReqOpUnique = false; + $hasReqOpIdx = false; + + foreach ($indexes as $idx) { + $name = (string) ($idx['Key_name'] ?? ''); + if ($name === 'uniq_abdm_hiu_req_op') { + $hasReqOpUnique = true; + } + if ($name === 'idx_abdm_hiu_req_op') { + $hasReqOpIdx = true; + } + } + + if ($hasReqOpUnique) { + $this->db->query('ALTER TABLE abdm_hiu_workflows DROP INDEX uniq_abdm_hiu_req_op'); + } + + if (! $hasReqOpIdx) { + $this->db->query('ALTER TABLE abdm_hiu_workflows ADD INDEX idx_abdm_hiu_req_op (request_id, operation)'); + } + } + + public function down(): void + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return; + } + + $indexes = $this->db->query('SHOW INDEX FROM abdm_hiu_workflows')->getResultArray(); + $hasReqOpUnique = false; + $hasReqOpIdx = false; + + foreach ($indexes as $idx) { + $name = (string) ($idx['Key_name'] ?? ''); + if ($name === 'uniq_abdm_hiu_req_op') { + $hasReqOpUnique = true; + } + if ($name === 'idx_abdm_hiu_req_op') { + $hasReqOpIdx = true; + } + } + + if ($hasReqOpIdx) { + $this->db->query('ALTER TABLE abdm_hiu_workflows DROP INDEX idx_abdm_hiu_req_op'); + } + + if (! $hasReqOpUnique) { + $this->db->query('ALTER TABLE abdm_hiu_workflows ADD UNIQUE INDEX uniq_abdm_hiu_req_op (request_id, operation)'); + } + } +} diff --git a/app/Libraries/Abdm/M3HiuGatewayClient.php b/app/Libraries/Abdm/M3HiuGatewayClient.php index 4e07a79..cb04340 100644 --- a/app/Libraries/Abdm/M3HiuGatewayClient.php +++ b/app/Libraries/Abdm/M3HiuGatewayClient.php @@ -14,6 +14,23 @@ class M3HiuGatewayClient $this->baseUrl = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/'); } + private function sanitizeBearerToken(string $token): string + { + $token = trim($token); + if ($token === '') { + return ''; + } + + if (stripos($token, 'Bearer ') === 0) { + $token = trim(substr($token, 7)); + } + + $token = trim($token, " \t\n\r\0\x0B\"'"); + $token = (string) preg_replace('/\s+/u', '', $token); + + return $token; + } + public function consentRequest(array $payload): array { return $this->call('/v3/hiu/consent/request', $payload, 'consent.request'); @@ -54,6 +71,10 @@ class M3HiuGatewayClient $token = (string) ($settings['token'] ?? ''); $hfrId = (string) ($settings['hfr_id'] ?? ''); $hospitalId = (string) ($settings['hospital_id'] ?? ''); + $runtimeBaseUrl = trim((string) ($settings['base_url'] ?? '')); + if ($runtimeBaseUrl !== '') { + $this->baseUrl = rtrim($runtimeBaseUrl, '/'); + } $payloadHfrId = trim((string) ($payload['hfr_id'] ?? '')); if ($payloadHfrId !== '' && strcasecmp($payloadHfrId, $hfrId) !== 0) { @@ -105,7 +126,7 @@ class M3HiuGatewayClient : ('HTTP ' . $httpCode . ' non-JSON response'); } - if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response ?? [])) { + if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $decoded ?? [])) { $errorText = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.'; } @@ -178,6 +199,10 @@ class M3HiuGatewayClient $token = (string) ($settings['token'] ?? ''); $hfrId = (string) ($settings['hfr_id'] ?? ''); $hospitalId = (string) ($settings['hospital_id'] ?? ''); + $runtimeBaseUrl = trim((string) ($settings['base_url'] ?? '')); + if ($runtimeBaseUrl !== '') { + $this->baseUrl = rtrim($runtimeBaseUrl, '/'); + } $payloadHfrId = trim((string) ($query['hfr_id'] ?? '')); if ($payloadHfrId !== '' && strcasecmp($payloadHfrId, $hfrId) !== 0) { @@ -305,24 +330,46 @@ class M3HiuGatewayClient return ['ok' => 0, 'http_code' => 500, 'error_text' => 'hospital_setting table not found', 'retryable' => 0]; } - $rows = $this->db->table('hospital_setting') + $settingFields = $this->db->getFieldNames('hospital_setting') ?? []; + $orderCol = null; + foreach (['id', 's_id'] as $candidateOrderCol) { + if (in_array($candidateOrderCol, $settingFields, true)) { + $orderCol = $candidateOrderCol; + break; + } + } + + $rowsBuilder = $this->db->table('hospital_setting') ->select('s_name, s_value') - ->whereIn('s_name', ['EATRIA_BRIDGE_TOKEN', 'ABDM_HFR_ID', 'ABDM_HOSPITAL_ID']) - ->get() - ->getResultArray(); + ->whereIn('s_name', [ + 'EATRIA_BRIDGE_TOKEN', + 'ABDM_HFR_ID', + 'H_HFR_ID', + 'ABDM_HOSPITAL_HFR_ID', + 'ABDM_HOSPITAL_ID', + 'ABDM_BRIDGE_HOSPITAL_ID', + 'EATRIA_BRIDGE_HOSPITAL_ID', + 'EATRIA_BRIDGE_URL', + ]); + if ($orderCol !== null) { + $rowsBuilder->orderBy($orderCol, 'DESC'); + } + + $rows = $rowsBuilder->get()->getResultArray(); $kv = []; foreach ($rows as $row) { $key = trim((string) ($row['s_name'] ?? '')); - if ($key === '') { + if ($key === '' || array_key_exists($key, $kv)) { continue; } $kv[$key] = trim((string) ($row['s_value'] ?? '')); } - $token = (string) ($kv['EATRIA_BRIDGE_TOKEN'] ?? ''); - $hfrId = (string) ($kv['ABDM_HFR_ID'] ?? ''); - $hospitalId = (string) ($kv['ABDM_HOSPITAL_ID'] ?? ''); + $token = $this->sanitizeBearerToken((string) ($kv['EATRIA_BRIDGE_TOKEN'] ?? '')); + $hfrId = (string) ($kv['ABDM_HFR_ID'] ?? $kv['H_HFR_ID'] ?? $kv['ABDM_HOSPITAL_HFR_ID'] ?? ''); + $hospitalId = (string) ($kv['ABDM_HOSPITAL_ID'] ?? $kv['ABDM_BRIDGE_HOSPITAL_ID'] ?? $kv['EATRIA_BRIDGE_HOSPITAL_ID'] ?? ''); + $baseUrl = trim((string) ($kv['EATRIA_BRIDGE_URL'] ?? '')); if ($token === '') { return [ @@ -346,6 +393,7 @@ class M3HiuGatewayClient 'token' => $token, 'hfr_id' => $hfrId, 'hospital_id' => $hospitalId, + 'base_url' => $baseUrl, ]; } diff --git a/app/Libraries/Abdm/M3HiuWorkflowService.php b/app/Libraries/Abdm/M3HiuWorkflowService.php index fcd4d7c..f2f8045 100644 --- a/app/Libraries/Abdm/M3HiuWorkflowService.php +++ b/app/Libraries/Abdm/M3HiuWorkflowService.php @@ -246,6 +246,7 @@ class M3HiuWorkflowService } $builder = $this->db->table('abdm_hiu_workflows')->select('*')->orderBy('id', 'DESC'); + $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; $hfrId = trim((string) ($filters['hfr_id'] ?? '')); if ($hfrId !== '') { @@ -253,7 +254,16 @@ class M3HiuWorkflowService } $consentId = trim((string) ($filters['consent_id'] ?? '')); if ($consentId !== '') { + $builder->groupStart(); $builder->where('consent_id', $consentId); + if (in_array('abdm_consent_request_id', $fields, true)) { + $builder->orWhere('abdm_consent_request_id', $consentId); + } + if (in_array('abdm_consent_artifact_id', $fields, true)) { + $builder->orWhere('abdm_consent_artifact_id', $consentId); + } + $builder->orWhere('request_id', $consentId); + $builder->groupEnd(); } $transactionId = trim((string) ($filters['transaction_id'] ?? '')); if ($transactionId !== '') { @@ -261,7 +271,12 @@ class M3HiuWorkflowService } $abhaAddress = trim((string) ($filters['abha_address'] ?? '')); if ($abhaAddress !== '') { + $builder->groupStart(); $builder->where('abha_address', $abhaAddress); + // Older rows may store ABHA address only inside request_json consent.patient.id. + $builder->orLike('request_json', '"abha_address":"' . str_replace('"', '\\"', $abhaAddress) . '"'); + $builder->orLike('request_json', '"id":"' . str_replace('"', '\\"', $abhaAddress) . '"'); + $builder->groupEnd(); } $from = trim((string) ($filters['date_from'] ?? '')); if ($from !== '') { @@ -356,6 +371,188 @@ class M3HiuWorkflowService ]; } + public function ingestConsentUpdateWebhook(array $payload): array + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return [ + 'ok' => 1, + 'persisted' => 0, + 'message' => 'abdm_hiu_workflows table not found; callback accepted without persistence', + ]; + } + + $consent = is_array($payload['consent'] ?? null) ? (array) $payload['consent'] : []; + $consentDetail = is_array($payload['consentDetail'] ?? null) ? (array) $payload['consentDetail'] : []; + $consentObj = ! empty($consent) ? $consent : $consentDetail; + + $status = strtolower(trim((string) ( + $consentObj['status'] + ?? $payload['consent_status'] + ?? $payload['status'] + ?? '' + ))); + if ($status === '') { + $status = 'status_checked'; + } + + $consentId = trim((string) ( + $consentObj['id'] + ?? $payload['consent_id'] + ?? $payload['consentId'] + ?? $payload['abdm_consent_artifact_id'] + ?? '' + )); + + $requestId = trim((string) ( + $payload['request_id'] + ?? $payload['requestId'] + ?? ($payload['response']['requestId'] ?? '') + ?? '' + )); + $transactionId = trim((string) ( + $payload['transaction_id'] + ?? $payload['transactionId'] + ?? ($consentObj['transactionId'] ?? '') + ?? '' + )); + + $abhaAddress = trim((string) ( + ($consentObj['patient']['id'] ?? '') + ?: ($payload['abha_address'] ?? '') + ?: ($payload['abha_id'] ?? '') + )); + $abhaAddress = $this->canonicalizeAbhaAddress($abhaAddress, (string) ($payload['abha_id'] ?? '')); + + $persistPayload = [ + 'request_id' => $requestId, + 'requestId' => $requestId, + 'transaction_id' => $transactionId, + 'transactionId' => $transactionId, + 'consent_id' => $consentId, + 'consentId' => $consentId, + 'abdm_consent_artifact_id' => $consentId, + 'abha_address' => $abhaAddress, + 'hfr_id' => trim((string) ($payload['hfr_id'] ?? '')), + 'consent' => $consentObj, + 'callback' => $payload, + ]; + + $result = [ + 'ok' => 1, + 'http_code' => 202, + 'status' => $status, + 'consent_id' => $consentId, + 'consentId' => $consentId, + 'abdm_consent_artifact_id' => $consentId, + 'request_id' => $requestId, + 'transaction_id' => $transactionId, + 'abha_address' => $abhaAddress, + 'consent' => $consentObj, + 'data' => $payload, + 'retryable' => 0, + ]; + + $state = match ($status) { + 'granted', 'approved', 'active' => 'GRANTED', + 'revoked', 'denied' => 'REVOKED', + 'expired' => 'EXPIRED', + default => 'STATUS_CHECKED', + }; + + $this->upsertWorkflow('consent_callback', $persistPayload, $result, $state, 'success', 202, false); + + return [ + 'ok' => 1, + 'persisted' => 1, + 'workflow_state' => $state, + 'consent_id' => $consentId, + 'request_id' => $requestId, + ]; + } + + public function ingestHealthInformationCallback(array $payload, string $operation = 'hi_on_request_callback'): array + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return [ + 'ok' => 1, + 'persisted' => 0, + 'message' => 'abdm_hiu_workflows table not found; callback accepted without persistence', + ]; + } + + $requestId = trim((string) ( + $payload['request_id'] + ?? $payload['requestId'] + ?? ($payload['response']['requestId'] ?? '') + ?? '' + )); + $transactionId = trim((string) ( + $payload['transaction_id'] + ?? $payload['transactionId'] + ?? ($payload['response']['transactionId'] ?? '') + ?? '' + )); + $consentId = trim((string) ( + ($payload['hiRequest']['consent']['id'] ?? '') + ?: ($payload['consent']['id'] ?? '') + ?: ($payload['consent_id'] ?? '') + ?: ($payload['consentId'] ?? '') + )); + $abhaAddress = $this->canonicalizeAbhaAddress(trim((string) ( + ($payload['patient']['id'] ?? '') + ?: ($payload['abha_address'] ?? '') + ?: ($payload['abha_id'] ?? '') + )), (string) ($payload['abha_id'] ?? '')); + + $errorCode = trim((string) ($payload['error']['code'] ?? '')); + $errorMessage = trim((string) ($payload['error']['message'] ?? '')); + $errorText = trim(($errorCode !== '' ? $errorCode . ' ' : '') . $errorMessage); + + $hasData = isset($payload['fhir_bundle']) + || isset($payload['fhirBundle']) + || isset($payload['bundles']) + || isset($payload['entries']) + || isset($payload['data']); + + $ok = $errorText === '' ? 1 : 0; + $state = $hasData ? 'DATA_RECEIVED' : ($ok === 1 ? 'DATA_PENDING' : 'FAILED'); + + $persistPayload = [ + 'request_id' => $requestId, + 'requestId' => $requestId, + 'transaction_id' => $transactionId, + 'transactionId' => $transactionId, + 'consent_id' => $consentId, + 'consentId' => $consentId, + 'abdm_consent_artifact_id' => $consentId, + 'abha_address' => $abhaAddress, + 'callback' => $payload, + ]; + $result = [ + 'ok' => $ok, + 'http_code' => 202, + 'status' => strtolower($state), + 'error_text' => $errorText, + 'request_id' => $requestId, + 'transaction_id' => $transactionId, + 'consent_id' => $consentId, + 'abha_address' => $abhaAddress, + 'retryable' => 0, + 'data' => $payload, + ]; + + $this->upsertWorkflow($operation, $persistPayload, $result, $state, $ok === 1 ? 'success' : 'failed', 202, false); + + return [ + 'ok' => 1, + 'persisted' => 1, + 'workflow_state' => $state, + 'request_id' => $requestId, + 'transaction_id' => $transactionId, + 'error_text' => $errorText, + ]; + } + private function dispatch(string $operation, array $payload): array { return match ($operation) { @@ -554,11 +751,14 @@ 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.', + '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)) { @@ -611,10 +811,11 @@ class M3HiuWorkflowService if (! isset($hiRequest['dateRange']) || ! is_array($hiRequest['dateRange'])) { $hiRequest['dateRange'] = [ - 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-30 days')), + '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'; } @@ -743,7 +944,7 @@ class M3HiuWorkflowService 'permission' => [ 'accessMode' => 'VIEW', 'dateRange' => [ - 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-30 days')), + 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-365 days')), 'to' => gmdate('Y-m-d\T00:00:00.000\Z'), ], 'dataEraseAt' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('+365 days')), @@ -793,15 +994,257 @@ class M3HiuWorkflowService 'consent' => [ 'id' => $consentId, ], - 'dateRange' => [ - 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-30 days')), + '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. + * + * @param array $fallbackRange + * @return array + */ + private function resolveSafeHiDateRange(string $requestIdRef, string $consentId, array $fallbackRange): 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 ($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')) { + 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, + ]; + + 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)) { + continue; + } + + $nested = $this->extractPermissionDateRange($value); + if (! empty($nested)) { + return $nested; + } + } + + 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; + } + private function readSettingValue(string $key): string { if (! $this->db->tableExists('hospital_setting')) { @@ -937,7 +1380,10 @@ class M3HiuWorkflowService 'consent_request' => 'REQUESTED', 'consent_status' => 'STATUS_CHECKED', 'consent_fetch' => 'CONSENT_FETCHED', + 'consent_callback' => ($statusStr === 'granted' || $statusStr === 'approved' ? 'GRANTED' : ($statusStr === 'revoked' ? 'REVOKED' : ($statusStr === 'expired' ? 'EXPIRED' : 'STATUS_CHECKED'))), 'hi_request' => ($statusStr === 'completed' ? 'COMPLETED' : 'DATA_REQUESTED'), + 'hi_on_request_callback' => (($statusStr === 'data_received' || isset($result['fhir_bundle']) || isset($result['bundles']) || isset($result['entries'])) ? 'DATA_RECEIVED' : ($ok ? 'DATA_PENDING' : 'FAILED')), + 'hi_data_push_callback' => (($statusStr === 'data_received' || isset($result['fhir_bundle']) || isset($result['bundles']) || isset($result['entries'])) ? 'DATA_RECEIVED' : ($ok ? 'DATA_PENDING' : 'FAILED')), 'consent_reconcile' => ($statusStr === 'granted' ? 'GRANTED' : ($statusStr === 'revoked' ? 'REVOKED' : ($statusStr === 'expired' ? 'EXPIRED' : 'STATUS_CHECKED'))), 'data_fetch' => (($statusStr === 'completed' || isset($result['fhir_bundle']) || isset($result['bundles'])) ? 'DATA_RECEIVED' : 'DATA_PENDING'), default => 'COMPLETED', @@ -949,6 +1395,47 @@ class M3HiuWorkflowService $requestId = trim((string) ($payload['request_id'] ?? '')); $transactionId = trim((string) ($payload['transaction_id'] ?? '')); + // HI operations should dedupe by transaction id so retries/new requests + // keep separate timeline rows instead of overwriting prior transactions. + if (in_array($operation, ['hi_request', 'data_fetch'], true)) { + if ($transactionId === '') { + return null; + } + + return $this->db->table('abdm_hiu_workflows') + ->select('*') + ->where('operation', $operation) + ->where('transaction_id', $transactionId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray() ?: null; + } + + if (in_array($operation, ['hi_on_request_callback', 'hi_data_push_callback'], true)) { + $builder = $this->db->table('abdm_hiu_workflows')->select('*')->where('operation', $operation); + if ($transactionId !== '') { + $row = $builder->where('transaction_id', $transactionId)->orderBy('id', 'DESC')->get(1)->getRowArray(); + if (! empty($row)) { + return $row; + } + } + + if ($requestId !== '') { + $row = $this->db->table('abdm_hiu_workflows') + ->select('*') + ->where('operation', $operation) + ->where('request_id', $requestId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($row)) { + return $row; + } + } + + return null; + } + $builder = $this->db->table('abdm_hiu_workflows')->select('*')->where('operation', $operation); if ($requestId !== '') { $row = $builder->where('request_id', $requestId)->orderBy('id', 'DESC')->get(1)->getRowArray(); @@ -1089,21 +1576,67 @@ class M3HiuWorkflowService int $forceId = 0 ): void { $now = Time::now('Asia/Kolkata')->toDateTimeString(); - $hmsRequestId = trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? '')); + $hmsRequestId = $this->firstNonEmptyString([ + $payload['request_id'] ?? null, + $payload['requestId'] ?? null, + ]); if ($hmsRequestId === '') { - $hmsRequestId = trim((string) ($result['hms_request_id'] ?? '')); + $hmsRequestId = $this->firstNonEmptyString([ + $result['hms_request_id'] ?? null, + ]); } - $gatewayRequestId = trim((string) ($result['gateway_request_id'] ?? $result['request_id'] ?? $result['requestId'] ?? '')); - $abdmConsentRequestId = trim((string) ($result['abdm_consent_request_id'] ?? $result['consent_request_id'] ?? $payload['consentRequestId'] ?? $payload['consent_request_id'] ?? '')); - $transactionId = trim((string) ($payload['transaction_id'] ?? $payload['transactionId'] ?? $result['transaction_id'] ?? '')); - $consentId = trim((string) ($payload['abdm_consent_artifact_id'] ?? $payload['consent_id'] ?? $payload['consentId'] ?? $result['abdm_consent_artifact_id'] ?? $result['consent_id'] ?? $result['consentId'] ?? '')); + $gatewayRequestId = $this->firstNonEmptyString([ + $result['gateway_request_id'] ?? null, + $result['request_id'] ?? null, + $result['requestId'] ?? null, + ]); + $abdmConsentRequestId = $this->firstNonEmptyString([ + $result['abdm_consent_request_id'] ?? null, + $result['consent_request_id'] ?? null, + $payload['consentRequestId'] ?? null, + $payload['consent_request_id'] ?? null, + ]); + $transactionId = $this->firstNonEmptyString([ + $payload['transaction_id'] ?? null, + $payload['transactionId'] ?? null, + $result['transaction_id'] ?? null, + $result['transactionId'] ?? null, + ]); + $consentId = $this->firstNonEmptyString([ + $payload['abdm_consent_artifact_id'] ?? null, + $payload['consent_id'] ?? null, + $payload['consentId'] ?? null, + $payload['hiRequest']['consent']['id'] ?? null, + $result['abdm_consent_artifact_id'] ?? null, + $result['consent_id'] ?? null, + $result['consentId'] ?? null, + $result['hiRequest']['consent']['id'] ?? null, + ]); if ($consentId === '' && $operation !== 'consent_fetch' && $operation !== 'hi_request') { $consentId = $abdmConsentRequestId; } - $abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abha_id'] ?? $result['abha_address'] ?? '')); - $hfrId = $this->normalizeStoredHfrId((string) ($payload['hfr_id'] ?? $result['hfr_id'] ?? '')); - $hospitalId = trim((string) ($payload['hospital_id'] ?? $result['hospital_id'] ?? '')); - $errorText = trim((string) ($result['error_text'] ?? $result['message'] ?? '')); + $abhaAddress = $this->firstNonEmptyString([ + $payload['abha_address'] ?? null, + $payload['abha_id'] ?? null, + $result['abha_address'] ?? null, + ]); + if ($abhaAddress === '') { + $abhaAddress = $this->firstNonEmptyString([ + $payload['consent']['patient']['id'] ?? null, + ]); + } + $hfrId = $this->normalizeStoredHfrId($this->firstNonEmptyString([ + $payload['hfr_id'] ?? null, + $result['hfr_id'] ?? null, + ])); + $hospitalId = $this->firstNonEmptyString([ + $payload['hospital_id'] ?? null, + $result['hospital_id'] ?? null, + ]); + $errorText = $this->firstNonEmptyString([ + $result['error_text'] ?? null, + $result['message'] ?? null, + ]); $retryCount = 0; $id = $forceId; @@ -1185,6 +1718,21 @@ class M3HiuWorkflowService return preg_match('/^REQ-/i', trim($value)) === 1; } + /** + * @param array $values + */ + private function firstNonEmptyString(array $values): string + { + foreach ($values as $value) { + $text = trim((string) ($value ?? '')); + if ($text !== '') { + return $text; + } + } + + return ''; + } + private function resolveAbdmConsentRequestIdFromWorkflow(array $payload): string { if (! $this->db->tableExists('abdm_hiu_workflows')) { @@ -1262,4 +1810,82 @@ class M3HiuWorkflowService return ''; } + + private function resolveAbdmConsentArtifactIdFromWorkflow(array $payload): string + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return ''; + } + + $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; + $hasAbdmConsentArtifactId = in_array('abdm_consent_artifact_id', $fields, true); + + $requestId = trim((string) ($payload['requestId'] ?? $payload['request_id'] ?? '')); + $transactionId = trim((string) ($payload['transactionId'] ?? $payload['transaction_id'] ?? '')); + $abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abha_id'] ?? '')); + + $tryBuilder = function (?string $whereCol, string $whereVal) use ($hasAbdmConsentArtifactId): string { + if ($whereCol === null || $whereVal === '') { + return ''; + } + + $select = ['consent_id', 'response_json']; + if ($hasAbdmConsentArtifactId) { + $select[] = 'abdm_consent_artifact_id'; + } + + $rows = $this->db->table('abdm_hiu_workflows') + ->select(implode(', ', $select)) + ->where($whereCol, $whereVal) + ->whereIn('operation', ['consent_fetch', 'consent_reconcile', 'consent_status', 'hi_request']) + ->orderBy('id', 'DESC') + ->get(25) + ->getResultArray(); + + foreach ($rows as $row) { + $candidate = ''; + if ($hasAbdmConsentArtifactId) { + $candidate = trim((string) ($row['abdm_consent_artifact_id'] ?? '')); + } + if ($candidate === '') { + $candidate = trim((string) ($row['consent_id'] ?? '')); + } + if ($candidate !== '' && ! $this->isGatewayRequestIdPattern($candidate)) { + return $candidate; + } + + $decoded = json_decode((string) ($row['response_json'] ?? ''), true); + if (is_array($decoded)) { + $candidate = trim((string) ( + $decoded['abdm_consent_artifact_id'] + ?? $decoded['consent_id'] + ?? $decoded['consentId'] + ?? '' + )); + if ($candidate !== '' && ! $this->isGatewayRequestIdPattern($candidate)) { + return $candidate; + } + } + } + + return ''; + }; + + $resolved = $tryBuilder('request_id', $requestId); + if ($resolved !== '') { + return $resolved; + } + + $resolved = $tryBuilder('transaction_id', $transactionId); + if ($resolved !== '') { + return $resolved; + } + + $resolved = $tryBuilder('abha_address', $abhaAddress); + if ($resolved !== '') { + return $resolved; + } + + return ''; + } } diff --git a/app/Views/abdm/hiu_m3.php b/app/Views/abdm/hiu_m3.php index edd3af6..43fb7a2 100644 --- a/app/Views/abdm/hiu_m3.php +++ b/app/Views/abdm/hiu_m3.php @@ -52,12 +52,55 @@ + + +
+
Workflow Todo
+
+
+ 1. Select Patient + 2. Create Consent Request + 3. Check Consent Status + 4. Access Consent Artifact + 5. Request Health Information +
+
+
+ +
Auto-filled context (read-only): request and consent IDs are managed from selected patient workflow.
-
-
-
-
+
+
+
+
@@ -74,6 +117,17 @@ +
+
+ + +
+
+ + +
+
+
{}
@@ -91,12 +145,18 @@
-
+
+
+
+
+
Recommended next step will appear after timeline loads.
+
+ @@ -113,6 +173,7 @@
Action ID Time Operation
+ @@ -124,15 +185,41 @@ var csrfName = ''; var csrfHash = ''; var selectedPatient = null; + var timelineItems = []; var consentPollTimer = null; var consentPollAttempts = 0; var consentPollMaxAttempts = 12; var recentPollErrors = []; + var autoCooldownUntilMs = 0; + var autoCooldownReason = ''; + var persistentTimelineMap = {}; + + function isAutoStatusPollingEnabled() { + var el = document.getElementById('toggleAutoStatusPolling'); + return !!(el && el.checked); + } + + function isAutoNextStepEnabled() { + var el = document.getElementById('toggleAutoNextStep'); + return !!(el && el.checked); + } + + function resolveConsentStateFromResponse(res, data) { + data = data || {}; + var consentObj = data.consent || {}; + return ( + (consentObj.status || '') || + (data.consent_status || '') || + (data.status || '') || + (res.workflow_state || '') || + (res.status || '') + ).toString(); + } var opRoute = { consent_request: '', consent_status: '', - consent_fetch: '', + consent_fetch: '', hi_request: '', data_fetch: '' }; @@ -186,11 +273,20 @@ var hasNum = selectedPatient.has_abha_number === 1; var hasAddr = selectedPatient.has_abha_address === 1; var state = (consentStateText || '').trim(); + var lockedCreate = hasExistingConsentFlowContext(); + var policyNote = lockedCreate + ? 'Policy: reuse logged consent flow (Status -> Fetch -> HI)' + : 'Policy: create consent only once per active flow'; + var cooldownNote = isAutoCoolingDown() + ? 'Auto-next paused: ' + escHtml(autoCooldownReason || 'temporary cooldown') + '' + : ''; box.innerHTML = '' + 'ABHA Number: ' + (hasNum ? 'Ready' : 'Missing') + '' + 'ABHA Address: ' + (hasAddr ? 'Ready' : 'Missing') + '' + - 'Consent: ' + escHtml(state || 'Unknown') + ''; + 'Consent: ' + escHtml(state || 'Unknown') + '' + + policyNote + + cooldownNote; } function isConsentApproved(statusText) { @@ -203,6 +299,11 @@ return ['approved', 'granted', 'active', 'completed', 'revoked', 'denied', 'expired', 'rejected', 'failed', 'error'].indexOf(s) !== -1; } + function isSuccessConsentState(statusText) { + var s = (statusText || '').toString().trim().toLowerCase(); + return ['approved', 'granted', 'active', 'status_checked', 'completed'].indexOf(s) !== -1; + } + function isGatewayRequestId(v) { return /^REQ-/i.test((v || '').toString().trim()); } @@ -212,25 +313,671 @@ return val !== '' && !isGatewayRequestId(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); + } + + function canCreateConsentRequest(consentStateText) { + if (!selectedPatient || selectedPatient.has_abha_number !== 1 || selectedPatient.has_abha_address !== 1) { + return false; + } + + // Policy: reuse existing consent flow context for patient instead of creating new consent requests. + if (hasExistingConsentFlowContext()) { + return false; + } + + var s = (consentStateText || '').toString().trim().toLowerCase(); + if (s !== '' && ['requested', 'status_checked', 'granted', 'approved', 'active'].indexOf(s) !== -1) { + return false; + } + + return true; + } + + function nowMs() { + return Date.now(); + } + + function isAutoCoolingDown() { + return nowMs() < autoCooldownUntilMs; + } + + function setAutoCooldown(ms, reason) { + autoCooldownUntilMs = nowMs() + Math.max(0, ms); + autoCooldownReason = (reason || '').toString(); + } + + function clearAutoCooldown() { + autoCooldownUntilMs = 0; + autoCooldownReason = ''; + } + + function parseJsonObject(raw) { + if (!raw) { + return {}; + } + if (typeof raw === 'object') { + return raw; + } + var txt = raw.toString().trim(); + if (!txt) { + return {}; + } + try { + var parsed = JSON.parse(txt); + return (parsed && typeof parsed === 'object') ? parsed : {}; + } catch (e) { + return {}; + } + } + + function pickFirstNonEmpty(values) { + for (var i = 0; i < values.length; i++) { + var v = (values[i] || '').toString().trim(); + if (v !== '') { + return v; + } + } + return ''; + } + + function extractWorkflowContext(row) { + var req = parseJsonObject(row && row.request_json ? row.request_json : '{}'); + var res = parseJsonObject(row && row.response_json ? row.response_json : '{}'); + var consent = parseJsonObject(res.consent || {}); + + var requestId = pickFirstNonEmpty([ + row && row.request_id, + req.request_id, + req.requestId + ]); + + var transactionId = pickFirstNonEmpty([ + row && row.transaction_id, + req.transaction_id, + req.transactionId, + res.transaction_id, + res.transactionId + ]); + + var abhaAddress = pickFirstNonEmpty([ + row && row.abha_address, + req.abha_address, + req.abhaAddress, + ((req.consent || {}).patient || {}).id + ]); + + var consentRequestId = pickFirstNonEmpty([ + row && row.abdm_consent_request_id, + req.abdm_consent_request_id, + req.consent_request_id, + req.consentRequestId, + res.abdm_consent_request_id, + res.consent_request_id, + res.consentRequestId, + consent.consent_request_id + ]); + + var consentArtifactIdRaw = pickFirstNonEmpty([ + row && row.abdm_consent_artifact_id, + row && row.consent_id, + req.abdm_consent_artifact_id, + req.consent_id, + req.consentId, + res.abdm_consent_artifact_id, + res.consent_id, + res.consentId + ]); + + return { + requestId: requestId, + transactionId: transactionId, + abhaAddress: abhaAddress, + hfrId: pickFirstNonEmpty([row && row.hfr_id, req.hfr_id]), + consentRequestId: isGatewayRequestId(consentRequestId) ? '' : consentRequestId, + consentArtifactId: isValidConsentArtifactId(consentArtifactIdRaw) ? consentArtifactIdRaw : '', + operation: (row && row.operation ? row.operation : '').toString().toLowerCase(), + workflowState: (row && row.workflow_state ? row.workflow_state : '').toString().toLowerCase(), + status: (row && row.status ? row.status : '').toString().toLowerCase(), + lastError: (row && row.last_error ? row.last_error : '').toString().toLowerCase() + }; + } + + function deriveConsentStatusText(row, ctx) { + var res = parseJsonObject(row && row.response_json ? row.response_json : '{}'); + var consent = parseJsonObject(res.consent || {}); + var rawStatus = pickFirstNonEmpty([ + consent.status, + res.consent_status, + '' + ]).toUpperCase(); + + if (rawStatus !== '') { + return rawStatus; + } + + var wf = (ctx.workflowState || '').toUpperCase(); + if (wf === 'REQUESTED') { + return 'REQUESTED'; + } + if (wf === 'CONSENT_FETCHED') { + return 'FETCHED'; + } + if (wf === 'STATUS_CHECKED') { + return 'STATUS_CHECKED'; + } + if (wf === 'DATA_REQUESTED' || wf === 'DATA_RECEIVED') { + return 'FETCHED'; + } + if ((ctx.status || '').toLowerCase() === 'failed') { + return 'FAILED'; + } + + return 'UNKNOWN'; + } + + function renderStatusBadge(statusText) { + var s = (statusText || '').toString().trim().toUpperCase(); + var cls = 'bg-secondary'; + if (['GRANTED', 'APPROVED', 'ACTIVE'].indexOf(s) !== -1) { + 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) { + cls = 'bg-info text-dark'; + } else if (['FAILED', 'DENIED', 'REVOKED', 'EXPIRED'].indexOf(s) !== -1) { + cls = 'bg-danger'; + } + + return '' + escHtml(s || 'UNKNOWN') + ''; + } + + function computeConsentRegistry(items) { + var byKey = {}; + + items.forEach(function (row, idx) { + var ctx = extractWorkflowContext(row); + var key = pickFirstNonEmpty([ + ctx.requestId, + ctx.consentRequestId, + ctx.consentArtifactId, + 'row-' + (row.id || idx) + ]); + + var record = { + key: key, + row_idx: idx, + row_id: parseInt(row.id || 0, 10) || 0, + request_id: ctx.requestId, + consent_request_id: ctx.consentRequestId, + consent_id: ctx.consentArtifactId, + consent_status: deriveConsentStatusText(row, ctx), + workflow_state: (row.workflow_state || '').toString(), + operation: (row.operation || '').toString(), + status: (row.status || '').toString(), + created_at: (row.created_at || '').toString(), + updated_at: (row.updated_at || row.created_at || '').toString(), + last_error: (row.last_error || '').toString() + }; + + var existing = byKey[key]; + if (!existing) { + byKey[key] = record; + } else if (record.row_id > existing.row_id) { + // Keep latest row for timestamps/state while preserving previously known consent identifiers/status. + if (!record.consent_id && existing.consent_id) { + record.consent_id = existing.consent_id; + } + if (!record.consent_request_id && existing.consent_request_id) { + record.consent_request_id = existing.consent_request_id; + } + if ((record.consent_status === '' || record.consent_status === 'UNKNOWN') && existing.consent_status) { + record.consent_status = existing.consent_status; + } + byKey[key] = record; + } else { + // Older row may still contain richer consent mapping fields. + if (!existing.consent_id && record.consent_id) { + existing.consent_id = record.consent_id; + } + if (!existing.consent_request_id && record.consent_request_id) { + existing.consent_request_id = record.consent_request_id; + } + if ((existing.consent_status === '' || existing.consent_status === 'UNKNOWN') && record.consent_status) { + existing.consent_status = record.consent_status; + } + byKey[key] = existing; + } + }); + + var list = Object.keys(byKey).map(function (k) { return byKey[k]; }); + list.sort(function (a, b) { return (b.row_id || 0) - (a.row_id || 0); }); + return list; + } + + function consentRowAction(rec) { + var status = (rec.consent_status || '').toUpperCase(); + var state = (rec.workflow_state || '').toUpperCase(); + var hasArtifact = (rec.consent_id || '').toString().trim() !== ''; + + if (status === 'REQUESTED' || state === 'REQUESTED') { + 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' }; + } + if (hasArtifact && (state === 'CONSENT_FETCHED' || status === 'FETCHED')) { + return { op: 'hi_request', label: 'Request Health Information', cls: 'btn-outline-success' }; + } + + if (hasArtifact) { + return { op: 'use', label: 'Use Context', cls: 'btn-outline-secondary' }; + } + + return { op: 'consent_status', label: 'Check Consent Status', cls: 'btn-outline-primary' }; + } + + function renderConsentLists(items) { + var panel = document.getElementById('patient_consent_window'); + var meta = document.getElementById('patient_consent_meta'); + var tbody = document.querySelector('#patient_consent_table tbody'); + + if (!panel || !meta || !tbody) { + return; + } + + if (!selectedPatient) { + panel.classList.add('d-none'); + return; + } + + panel.classList.remove('d-none'); + meta.textContent = 'Patient: ' + (selectedPatient.patient_name || '-') + ' | UHID: ' + (selectedPatient.patient_code || '-') + ' | ABHA: ' + (selectedPatient.abha_address || '-'); + + var selectedAbha = (selectedPatient.abha_address || '').toString().trim(); + var selectedItems = (items || []).filter(function (row) { + var ctx = extractWorkflowContext(row); + return selectedAbha !== '' && ctx.abhaAddress === selectedAbha; + }); + + var registry = computeConsentRegistry(selectedItems); + updateWorkflowTodo(selectedItems, registry); + + tbody.innerHTML = ''; + if (!registry.length) { + tbody.innerHTML = 'No consent requests found for selected patient.'; + return; + } + + registry.forEach(function (rec) { + var action = consentRowAction(rec); + var tr = document.createElement('tr'); + tr.innerHTML = '' + + '' + + '' + escHtml(rec.consent_request_id || rec.request_id || '-') + '' + + '' + escHtml(rec.consent_id || '-') + '' + + '' + renderStatusBadge(rec.consent_status || rec.workflow_state || '-') + '' + + '' + escHtml(rec.created_at || '-') + '' + + '' + escHtml(rec.updated_at || '-') + ''; + tbody.appendChild(tr); + }); + + tbody.querySelectorAll('button[data-consent-row-idx]').forEach(function (btn) { + btn.addEventListener('click', function () { + var idx = parseInt((btn.getAttribute('data-consent-row-idx') || ''), 10); + var op = (btn.getAttribute('data-consent-action') || 'use').toString(); + if (Number.isNaN(idx) || !timelineItems[idx]) { + setResult({ ok: 0, message: 'Unable to resolve consent row context.' }); + return; + } + runTimelineAction(idx, op); + }); + }); + } + + function updateWorkflowTodo(selectedItems, registry) { + var card = document.getElementById('workflow_todo_card'); + var itemsHost = document.getElementById('workflow_todo_items'); + if (!card || !itemsHost) { + return; + } + + if (!selectedPatient) { + card.classList.add('d-none'); + return; + } + card.classList.remove('d-none'); + + var latest = (registry && registry.length) ? registry[0] : null; + var latestStatus = latest ? (latest.consent_status || '').toString().toUpperCase() : ''; + var latestState = latest ? (latest.workflow_state || '').toString().toUpperCase() : ''; + var hasConsentRequest = !!(latest && (latest.consent_request_id || latest.request_id)); + var hasStatusChecked = latestState === 'STATUS_CHECKED' || latestStatus === 'STATUS_CHECKED' || latestStatus === 'GRANTED'; + var hasArtifact = !!(latest && latest.consent_id); + + var selectedAbha = (selectedPatient.abha_address || '').toString().trim(); + var hasHiRequest = (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'; + }); + + var done = { + 1: true, + 2: hasConsentRequest, + 3: hasStatusChecked, + 4: hasArtifact, + 5: hasHiRequest + }; + + itemsHost.querySelectorAll('span[data-step]').forEach(function (el) { + var step = parseInt(el.getAttribute('data-step') || '0', 10); + if (done[step]) { + el.className = 'badge bg-success'; + } else { + el.className = 'badge bg-secondary'; + } + }); + } + + function mergeTimelineItems(items) { + (items || []).forEach(function (row) { + var key = (row && row.id !== undefined && row.id !== null) ? ('id:' + row.id) : null; + if (key) { + persistentTimelineMap[key] = row; + } + }); + + var merged = Object.keys(persistentTimelineMap).map(function (k) { return persistentTimelineMap[k]; }); + merged.sort(function (a, b) { + return (parseInt(b.id || 0, 10) || 0) - (parseInt(a.id || 0, 10) || 0); + }); + + return merged; + } + + function renderTimelineRows(rows) { + var tbody = document.querySelector('#timeline_table tbody'); + if (!tbody) { + return; + } + tbody.innerHTML = ''; + timelineItems = Array.isArray(rows) ? rows : []; + + timelineItems.forEach(function (row, idx) { + var ctx = extractWorkflowContext(row); + var nextAction = getRowNextAction(ctx); + var canStatus = !!(ctx.requestId || ctx.consentRequestId || ctx.consentArtifactId); + var canFetch = !!ctx.consentArtifactId; + + var rowIdx = idx; + var actions = []; + actions.push(''); + if (nextAction && nextAction.op) { + actions.push(''); + } + + if (canStatus) { + actions.push(''); + } + if (canFetch) { + actions.push(''); + actions.push(''); + } + + var tr = document.createElement('tr'); + tr.innerHTML = '' + + '
' + actions.join('') + '
' + + '' + (row.id || '') + '' + + '' + (row.created_at || '') + '' + + '' + (row.operation || '') + '' + + '' + (row.workflow_state || '') + '' + + '' + (row.status || '') + '' + + '' + (row.hfr_id || '') + '' + + '' + (row.abha_address || '') + '' + + '' + (row.consent_id || '') + '' + + '' + (row.transaction_id || '') + '' + + '' + (row.request_id || '') + '' + + '' + (row.last_error || '') + ''; + tbody.appendChild(tr); + }); + + tbody.querySelectorAll('button[data-row-action]').forEach(function (btn) { + btn.addEventListener('click', function () { + var action = (btn.getAttribute('data-row-action') || '').toString(); + var idx = parseInt(btn.getAttribute('data-row-idx'), 10); + if (!action || Number.isNaN(idx) || !timelineItems[idx]) { + setResult({ ok: 0, message: 'Invalid timeline action context.' }); + return; + } + runTimelineAction(idx, action); + }); + }); + } + + function getRowNextAction(ctx) { + var hasStatusRef = !!(ctx.requestId || ctx.consentRequestId || ctx.consentArtifactId); + var hasArtifact = !!ctx.consentArtifactId; + + if (ctx.status === 'failed') { + if (ctx.lastError.indexOf('consentid is required') !== -1 && hasStatusRef) { + return { op: 'consent_status', label: 'Check Consent Status' }; + } + 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 === 'consent_request' && hasStatusRef) { + return { op: 'consent_status', label: 'Check Consent Status' }; + } + + if (ctx.operation === 'consent_status' || ctx.operation === 'consent_reconcile') { + 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 (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' }; + } + + return null; + } + + function getLatestTimelineRow() { + if (!timelineItems.length) { + return null; + } + return timelineItems[0] || null; + } + + function suggestNextStep(row) { + if (!row) { + return { + level: 'muted', + message: 'No timeline rows found yet. Click Apply Filters or run an operation first.' + }; + } + + var ctx = extractWorkflowContext(row); + var operation = ctx.operation; + var state = ctx.workflowState; + var status = ctx.status; + var error = ctx.lastError; + var hasArtifact = !!ctx.consentArtifactId; + var nextAction = getRowNextAction(ctx); + + 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.' }; + } + 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.' }; + } + if (error.indexOf('cloudfront') !== -1 || error.indexOf('request blocked') !== -1) { + return { level: 'danger', message: 'Gateway edge blocked the call (CloudFront 403). Retry later or ask gateway team to unblock HIU endpoints.' }; + } + return { level: 'warning', message: 'Latest operation failed. Review error column and retry the appropriate step.' }; + } + + if (operation === 'consent_request') { + return { level: 'info', message: 'Next step: ' + (nextAction ? nextAction.label : 'Check Consent Status') + ' until patient approval is reflected.' }; + } + if (operation === 'consent_status' || operation === 'consent_reconcile') { + if (state === 'requested') { + 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.' }; + } + 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.' }; + } + } + 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.' }; + } + + return { level: 'muted', message: 'Use this row context and continue with the next HIU operation.' }; + } + + function renderNextStepHint(row) { + var box = document.getElementById('log_next_step'); + if (!box) { + return; + } + var hint = suggestNextStep(row); + var classMap = { + success: 'alert alert-success py-2 px-3 mb-0 small', + info: 'alert alert-info py-2 px-3 mb-0 small', + warning: 'alert alert-warning py-2 px-3 mb-0 small', + danger: 'alert alert-danger py-2 px-3 mb-0 small', + muted: 'alert alert-light py-2 px-3 mb-0 small text-muted' + }; + box.className = classMap[hint.level] || classMap.muted; + box.textContent = hint.message; + } + + function applyLogContext(row, options) { + options = options || {}; + if (!row) { + if (options.showResult !== false) { + setResult({ ok: 0, message: 'No timeline row found to apply.' }); + } + return; + } + + var ctx = extractWorkflowContext(row); + var requestId = ctx.requestId; + var transactionId = ctx.transactionId; + var hfrId = ctx.hfrId; + var abhaAddress = ctx.abhaAddress; + var consentRequestId = ctx.consentRequestId; + var consentArtifactId = ctx.consentArtifactId; + var nextAction = getRowNextAction(ctx); + + if (requestId !== '') { + setFieldValue(['request_id'], requestId); + } + if (transactionId !== '') { + setFieldValue(['transaction_id'], transactionId); + } + if (abhaAddress !== '') { + setFieldValue(['abha_address'], abhaAddress); + } + if (consentRequestId !== '') { + setFieldValue(['consent_request_id', 'consent_id'], consentRequestId); + } + if (consentArtifactId !== '') { + setFieldValue(['consent_artifact_id', 'consent_id'], consentArtifactId); + } + + syncButtons((row.workflow_state || row.status || '').toString()); + renderNextStepHint(row); + + if (options.showResult !== false) { + setResult({ + ok: 1, + message: 'Applied log context to input fields.', + operation: row.operation || '', + workflow_state: row.workflow_state || '', + status: row.status || '', + request_id: requestId, + consent_request_id: consentRequestId, + consent_artifact_id: consentArtifactId, + next_action: nextAction ? nextAction.op : '' + }); + } + + return ctx; + } + + function runTimelineAction(idx, op) { + if (Number.isNaN(idx) || !timelineItems[idx]) { + setResult({ ok: 0, message: 'Timeline row not found for action.' }); + return; + } + var row = timelineItems[idx]; + var ctx = extractWorkflowContext(row); + var nextAction = getRowNextAction(ctx); + + if (op === 'auto_next') { + if (!nextAction || !nextAction.op) { + setResult({ ok: 0, message: 'No recommended next action for this row.' }); + return; + } + applyLogContext(row, { showResult: false }); + run(nextAction.op); + return; + } + + applyLogContext(row, { showResult: false }); + if (op === 'use') { + setResult({ ok: 1, message: 'Log context applied. You can run the next step now.' }); + return; + } + run(op); + } + function syncButtons(consentStateText) { var hasPatient = !!selectedPatient; - var hasNum = hasPatient && selectedPatient.has_abha_number === 1; - var hasAddr = hasPatient && selectedPatient.has_abha_address === 1; 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 canFetchByState = isConsentApproved(consentStateText) || isTerminalConsentState(consentStateText); - document.getElementById('btnConsentRequest').disabled = !(hasNum && hasAddr); + document.getElementById('btnConsentRequest').disabled = !canCreateConsentRequest(consentStateText); document.getElementById('btnConsentStatus').disabled = !hasConsentRequestRef; - document.getElementById('btnConsentFetch').disabled = !(hasConsentArtifactRef || (hasConsentRequestRef && canFetchByState)); + document.getElementById('btnConsentFetch').disabled = !(hasConsentArtifactRef && isSuccessConsentState(consentStateText) && canFetchByState); document.getElementById('btnHiRequest').disabled = !(hasConsentArtifactRef && isConsentApproved(consentStateText)); updateReadinessBox(consentStateText); } function setSelectedPatient(p) { selectedPatient = p; + persistentTimelineMap = {}; setFieldValue(['selected_patient'], (p.patient_name || '') + (p.patient_code ? ' (' + p.patient_code + ')' : '')); setFieldValue(['abha_address'], p.abha_address || ''); setFieldValue(['request_id'], genReq('REQ-CONSENT')); @@ -241,7 +988,6 @@ var consentArtifactRef = (latest.abdm_consent_artifact_id || latest.consent_id || '').toString(); if (consentRequestRef && !isGatewayRequestId(consentRequestRef)) { setFieldValue(['consent_request_id', 'consent_id'], consentRequestRef); - setFieldValue(['f_consent_id'], consentRequestRef); } if (isValidConsentArtifactId(consentArtifactRef)) { setFieldValue(['consent_artifact_id', 'consent_id'], consentArtifactRef); @@ -350,6 +1096,9 @@ requestId: requestId, timestamp: timestamp || new Date().toISOString() }; + if (abhaAddress) { + base.abha_address = abhaAddress; + } if (op === 'consent_request') { var consent = raw.consent; @@ -369,7 +1118,7 @@ permission: { accessMode: 'VIEW', dateRange: { - from: new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)).toISOString(), + from: new Date(Date.now() - (365 * 24 * 60 * 60 * 1000)).toISOString(), to: new Date().toISOString() }, dataEraseAt: new Date(Date.now() + (365 * 24 * 60 * 60 * 1000)).toISOString(), @@ -396,13 +1145,13 @@ return base; } - if (op === 'consent_status' || op === 'consent_fetch') { + if (op === 'consent_status') { var requestIdRef = (raw.request_id || raw.requestId || '').toString().trim(); var consentRequestId = (raw.abdm_consent_request_id || raw.consentRequestId || '').toString().trim(); var consentIdRef = (raw.abdm_consent_artifact_id || raw.consentId || raw.consent_id || '').toString().trim(); if (!requestIdRef && !consentRequestId && !consentIdRef) { - throw new Error('Provide one of request_id, consentRequestId, or consentId for consent reconciliation.'); + throw new Error('Provide one of request_id, consentRequestId, or consentId for consent status check.'); } if (requestIdRef) { @@ -419,13 +1168,26 @@ if (op === 'consent_fetch') { var consentId = (raw.abdm_consent_artifact_id || raw.consentId || '').toString().trim(); - if (!consentId) { - throw new Error('consentId is required for consent fetch.'); - } - if (isGatewayRequestId(consentId)) { + 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.'); } - base.consentId = consentId; + + 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; } @@ -437,7 +1199,7 @@ id: (raw.consentId || raw.consent_id || '').toString().trim() }, dateRange: { - from: new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)).toISOString(), + 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', @@ -492,6 +1254,15 @@ setResult({ ok: 0, message: 'Unsupported operation' }); return; } + + if (op === 'consent_request' && hasExistingConsentFlowContext()) { + setResult({ + ok: 0, + message: 'Consent request already exists for this patient flow. Continue with Check Consent Status.' + }); + syncButtons(); + return; + } var payload; try { payload = buildGatewayPayload(op, readPayload(op)); @@ -509,7 +1280,6 @@ var consentReqRef = (data.abdm_consent_request_id || data.consentRequestId || data.consent_request_id || '').toString(); if (consentReqRef && !isGatewayRequestId(consentReqRef)) { setFieldValue(['consent_request_id', 'consent_id'], consentReqRef); - setFieldValue(['f_consent_id'], consentReqRef); } var consentArtifactRef = (data.abdm_consent_artifact_id || data.consentId || data.consent_id || '').toString(); @@ -517,24 +1287,45 @@ setFieldValue(['consent_artifact_id', 'consent_id'], consentArtifactRef); } - var state = (data.workflow_state || data.consent_status || data.status || res.workflow_state || '').toString(); + var state = resolveConsentStateFromResponse(res, data); syncButtons(state); if (op === 'consent_request') { startConsentPolling(); } - if ((op === 'consent_status' || op === 'consent_fetch') && isConsentApproved(state) && isValidConsentArtifactId(consentArtifactRef)) { - run('hi_request', { silent: true }); + + // Ordered workflow automation: status success -> fetch artifact, then fetch success -> request HI. + if (op === 'consent_status' && isSuccessConsentState(state) && isValidConsentArtifactId(consentArtifactRef)) { + if (isAutoNextStepEnabled() && !isAutoCoolingDown()) { + run('consent_fetch', { silent: true }); + } + } + if (op === 'consent_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)) { stopConsentPolling(); } } else if (op === 'consent_status' || op === 'consent_fetch') { - var errState = ((res.data || {}).workflow_state || res.workflow_state || '').toString(); + 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(); + } } fetchTimeline(); @@ -544,6 +1335,9 @@ } function startConsentPolling() { + if (!isAutoStatusPollingEnabled()) { + return; + } stopConsentPolling(); consentPollAttempts = 0; consentPollTimer = setInterval(function () { @@ -630,11 +1424,37 @@ } function fetchTimeline() { + function hasExplicitTimelineFilter() { + return getFieldValue(['f_hfr_id']) !== '' + || getFieldValue(['f_consent_id']) !== '' + || getFieldValue(['f_transaction_id']) !== '' + || getFieldValue(['f_abha_address']) !== '' + || getFieldValue(['f_date_from']) !== '' + || getFieldValue(['f_date_to']) !== ''; + } + + if (!selectedPatient && !hasExplicitTimelineFilter()) { + timelineItems = []; + persistentTimelineMap = {}; + renderTimelineRows([]); + renderNextStepHint(null); + var tbody = document.querySelector('#timeline_table tbody'); + if (tbody) { + tbody.innerHTML = 'Select a patient to view workflow logs, or use filters to query global logs.'; + } + return; + } + + var abhaFilter = getFieldValue(['f_abha_address']); + if (abhaFilter === '' && selectedPatient && selectedPatient.abha_address) { + abhaFilter = selectedPatient.abha_address; + } + var qs = new URLSearchParams({ hfr_id: getFieldValue(['f_hfr_id']), consent_id: getFieldValue(['f_consent_id']), transaction_id: getFieldValue(['f_transaction_id']), - abha_address: getFieldValue(['f_abha_address']), + abha_address: abhaFilter, date_from: getFieldValue(['f_date_from']), date_to: getFieldValue(['f_date_to']) }); @@ -644,29 +1464,31 @@ }) .then(function (r) { return r.json(); }) .then(function (res) { - var tbody = document.querySelector('#timeline_table tbody'); - tbody.innerHTML = ''; - (res.items || []).forEach(function (row) { - var tr = document.createElement('tr'); - tr.innerHTML = '' + - '' + (row.id || '') + '' + - '' + (row.created_at || '') + '' + - '' + (row.operation || '') + '' + - '' + (row.workflow_state || '') + '' + - '' + (row.status || '') + '' + - '' + (row.hfr_id || '') + '' + - '' + (row.abha_address || '') + '' + - '' + (row.consent_id || '') + '' + - '' + (row.transaction_id || '') + '' + - '' + (row.request_id || '') + '' + - '' + (row.last_error || '') + ''; - tbody.appendChild(tr); - }); + var incomingItems = Array.isArray(res.items) ? res.items : []; + if (incomingItems.length) { + mergeTimelineItems(incomingItems); + } + + var mergedItems = mergeTimelineItems([]); + renderTimelineRows(mergedItems); + renderConsentLists(mergedItems); + + renderNextStepHint(getLatestTimelineRow()); refreshPollSummary(); }); } + function clearTimelineFilters() { + setFieldValue(['f_hfr_id'], ''); + setFieldValue(['f_consent_id'], ''); + setFieldValue(['f_transaction_id'], ''); + setFieldValue(['f_abha_address'], ''); + setFieldValue(['f_date_from'], ''); + setFieldValue(['f_date_to'], ''); + fetchTimeline(); + } + var btnPatientSearch = document.getElementById('btnPatientSearch'); if (btnPatientSearch) { btnPatientSearch.addEventListener('click', searchPatients); @@ -709,6 +1531,16 @@ if (btnRefreshTimeline) { btnRefreshTimeline.addEventListener('click', fetchTimeline); } + var btnUseLatestLog = document.getElementById('btnUseLatestLog'); + if (btnUseLatestLog) { + btnUseLatestLog.addEventListener('click', function () { + applyLogContext(getLatestTimelineRow()); + }); + } + var btnClearFilters = document.getElementById('btnClearFilters'); + if (btnClearFilters) { + btnClearFilters.addEventListener('click', clearTimelineFilters); + } var btnPollErrors = document.getElementById('btnPollErrors'); if (btnPollErrors) { btnPollErrors.addEventListener('click', function () { @@ -726,6 +1558,37 @@ }); } + var btnStartFreshFlow = document.getElementById('btnStartFreshFlow'); + if (btnStartFreshFlow) { + btnStartFreshFlow.addEventListener('click', function () { + setFieldValue(['request_id'], genReq('REQ-CONSENT')); + setFieldValue(['transaction_id'], genReq('TXN-HIU')); + setFieldValue(['consent_request_id', 'consent_id'], ''); + setFieldValue(['consent_artifact_id'], ''); + syncButtons(); + setResult({ ok: 1, message: 'Started fresh flow context for selected patient.' }); + }); + } + + var toggleAutoStatusPolling = document.getElementById('toggleAutoStatusPolling'); + if (toggleAutoStatusPolling) { + toggleAutoStatusPolling.checked = false; + toggleAutoStatusPolling.addEventListener('change', function () { + if (!toggleAutoStatusPolling.checked) { + stopConsentPolling(); + } + syncButtons(); + }); + } + + var toggleAutoNextStep = document.getElementById('toggleAutoNextStep'); + if (toggleAutoNextStep) { + toggleAutoNextStep.checked = false; + toggleAutoNextStep.addEventListener('change', function () { + syncButtons(); + }); + } + syncButtons(); refreshPollSummary(); fetchTimeline(); diff --git a/app/Views/billing/opd_appointment_V.php b/app/Views/billing/opd_appointment_V.php index 2a5e695..56e6251 100644 --- a/app/Views/billing/opd_appointment_V.php +++ b/app/Views/billing/opd_appointment_V.php @@ -30,12 +30,6 @@ -
- - -
@@ -95,7 +89,7 @@ $('#showfee').hide(); - $('#btnnextfee').click(function() { + $('#btnnextfee').off('click.opdAppt').on('click.opdAppt', function() { var checkValue = $('#rdoc_id:checked').val(); if (!checkValue) { alert('Please Select Doctor Name'); @@ -117,12 +111,12 @@ $('#ShowDoctor').hide(); }); - $('#backtodoc').click(function() { + $('#backtodoc').off('click.opdAppt').on('click.opdAppt', function() { $('#ShowDoctor').show(); $('#showfee').hide(); }); - $('#btnnextconfirm').click(function() { + $('#btnnextconfirm').off('click.opdAppt').on('click.opdAppt', function() { var checkValue = $('#fee_id:checked').val(); if (!checkValue) { alert('Please Select Fee Name'); @@ -138,7 +132,6 @@ "pid": $('#pid').val(), "datepicker_appointment": $('#datepicker_appointment').val(), "appointment_time": $('#appointment_time').val(), - "abha_address": $('#abha_address').val(), [csrf.name]: csrf.value }, function(data) { updateCsrf(data); diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php index 346623f..41f854c 100644 --- a/app/Views/welcome_message.php +++ b/app/Views/welcome_message.php @@ -255,12 +255,8 @@ delete_varible(); } $("#wait").css("display", "none"); - var mainEl = document.getElementById('main'); - if (mainEl) { - mainEl.innerHTML = html; - } else { - $("#main").html(html); - } + // Use jQuery html() so old node handlers/data are cleaned up. + $("#main").html(html); executeInjectedScripts('main'); if (typeof initfunc === 'function') { initfunc(); @@ -306,12 +302,8 @@ timeout: REQUEST_TIMEOUT_MS }) .done(function(html) { - var targetEl = document.getElementById(xdiv); - if (targetEl) { - targetEl.innerHTML = html; - } else { - $("#" + xdiv).html(html); - } + // Use jQuery html() so old node handlers/data are cleaned up. + $("#" + xdiv).html(html); executeInjectedScripts(xdiv); if (typeof initfunc === 'function') { initfunc();