diff --git a/app/Controllers/AbdmHiu.php b/app/Controllers/AbdmHiu.php index c04210e..edee91c 100644 --- a/app/Controllers/AbdmHiu.php +++ b/app/Controllers/AbdmHiu.php @@ -163,6 +163,7 @@ class AbdmHiu extends BaseController if ($abhaAddress === '') { $abhaAddress = $this->extractAbhaAddressFromLog((string) ($row['patient_log'] ?? '')); } + $abhaAddress = $this->canonicalizeAbhaAddress($abhaAddress, $abhaNumber); $latest = $this->findLatestConsentByAbhaAddress($abhaAddress); $items[] = [ @@ -173,7 +174,7 @@ class AbdmHiu extends BaseController 'abha_number' => $abhaNumber, 'abha_address' => $abhaAddress, 'has_abha_number' => preg_match('/^\d{14}$/', $abhaNumber) === 1 ? 1 : 0, - 'has_abha_address' => $abhaAddress !== '' ? 1 : 0, + 'has_abha_address' => $this->isValidAbhaAddress($abhaAddress) ? 1 : 0, 'latest_consent' => $latest, ]; } @@ -228,8 +229,27 @@ class AbdmHiu extends BaseController return null; } + $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; + $select = [ + 'id', + 'operation', + 'workflow_state', + 'status', + 'consent_id', + 'request_id', + 'transaction_id', + 'created_at', + 'updated_at', + ]; + if (in_array('abdm_consent_request_id', $fields, true)) { + $select[] = 'abdm_consent_request_id'; + } + if (in_array('abdm_consent_artifact_id', $fields, true)) { + $select[] = 'abdm_consent_artifact_id'; + } + $row = $this->db->table('abdm_hiu_workflows') - ->select('id, operation, workflow_state, status, consent_id, request_id, transaction_id, created_at, updated_at') + ->select(implode(', ', $select)) ->where('abha_address', $abhaAddress) ->whereIn('operation', ['consent_request', 'consent_status', 'consent_fetch']) ->orderBy('id', 'DESC') @@ -262,4 +282,29 @@ class AbdmHiu extends BaseController return ''; } + + private function canonicalizeAbhaAddress(string $abhaAddress, string $abhaNumber): string + { + $candidate = trim($abhaAddress); + if ($candidate !== '' && $this->isValidAbhaAddress($candidate)) { + return $candidate; + } + + $addressDigits = preg_replace('/\D+/', '', $candidate) ?? ''; + if (preg_match('/^\d{14}$/', $addressDigits) !== 1) { + $addressDigits = preg_replace('/\D+/', '', trim($abhaNumber)) ?? ''; + } + + if (preg_match('/^\d{14}$/', $addressDigits) === 1) { + $domain = trim((string) (getenv('ABDM_ABHA_ADDRESS_DEFAULT_DOMAIN') ?: 'sbx')); + return $addressDigits . '@' . $domain; + } + + return ''; + } + + private function isValidAbhaAddress(string $value): bool + { + return preg_match('/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/', trim($value)) === 1; + } } diff --git a/app/Database/Migrations/2026-06-27-000613_CreateAbdmPushSyncTables.php b/app/Database/Migrations/2026-06-27-000613_CreateAbdmPushSyncTables.php index 4630b23..db615ed 100644 --- a/app/Database/Migrations/2026-06-27-000613_CreateAbdmPushSyncTables.php +++ b/app/Database/Migrations/2026-06-27-000613_CreateAbdmPushSyncTables.php @@ -145,7 +145,7 @@ class CreateAbdmPushSyncTables extends Migration ], 'care_context_reference' => [ 'type' => 'VARCHAR', - 'constraint' => 255, + 'constraint' => 191, 'null' => false, ], 'care_context_display' => [ diff --git a/app/Database/Migrations/2026-07-01-000702_AddConsentMappingColumnsToAbdmHiuWorkflows.php b/app/Database/Migrations/2026-07-01-000702_AddConsentMappingColumnsToAbdmHiuWorkflows.php new file mode 100644 index 0000000..ba9ab29 --- /dev/null +++ b/app/Database/Migrations/2026-07-01-000702_AddConsentMappingColumnsToAbdmHiuWorkflows.php @@ -0,0 +1,111 @@ +db->tableExists('abdm_hiu_workflows')) { + return; + } + + $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; + + $add = []; + if (! in_array('hms_request_id', $fields, true)) { + $add['hms_request_id'] = [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + 'after' => 'request_id', + ]; + } + if (! in_array('gateway_request_id', $fields, true)) { + $add['gateway_request_id'] = [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + 'after' => 'hms_request_id', + ]; + } + if (! in_array('abdm_consent_request_id', $fields, true)) { + $add['abdm_consent_request_id'] = [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + 'after' => 'consent_id', + ]; + } + if (! in_array('abdm_consent_artifact_id', $fields, true)) { + $add['abdm_consent_artifact_id'] = [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + 'after' => 'abdm_consent_request_id', + ]; + } + + if (! empty($add)) { + $this->forge->addColumn('abdm_hiu_workflows', $add); + } + + $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; + if (in_array('abdm_consent_request_id', $fields, true)) { + if (! $this->indexExists('abdm_hiu_workflows', 'idx_abdm_hiu_abdm_consent_req')) { + $this->db->query('CREATE INDEX idx_abdm_hiu_abdm_consent_req ON abdm_hiu_workflows (abdm_consent_request_id)'); + } + } + if (in_array('hms_request_id', $fields, true)) { + if (! $this->indexExists('abdm_hiu_workflows', 'idx_abdm_hiu_hms_req')) { + $this->db->query('CREATE INDEX idx_abdm_hiu_hms_req ON abdm_hiu_workflows (hms_request_id)'); + } + } + if (in_array('gateway_request_id', $fields, true)) { + if (! $this->indexExists('abdm_hiu_workflows', 'idx_abdm_hiu_gateway_req')) { + $this->db->query('CREATE INDEX idx_abdm_hiu_gateway_req ON abdm_hiu_workflows (gateway_request_id)'); + } + } + if (in_array('abdm_consent_artifact_id', $fields, true)) { + if (! $this->indexExists('abdm_hiu_workflows', 'idx_abdm_hiu_abdm_consent_artifact')) { + $this->db->query('CREATE INDEX idx_abdm_hiu_abdm_consent_artifact ON abdm_hiu_workflows (abdm_consent_artifact_id)'); + } + } + } + + public function down(): void + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return; + } + + $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; + + if (in_array('hms_request_id', $fields, true)) { + $this->forge->dropColumn('abdm_hiu_workflows', 'hms_request_id'); + } + if (in_array('gateway_request_id', $fields, true)) { + $this->forge->dropColumn('abdm_hiu_workflows', 'gateway_request_id'); + } + if (in_array('abdm_consent_request_id', $fields, true)) { + $this->forge->dropColumn('abdm_hiu_workflows', 'abdm_consent_request_id'); + } + if (in_array('abdm_consent_artifact_id', $fields, true)) { + $this->forge->dropColumn('abdm_hiu_workflows', 'abdm_consent_artifact_id'); + } + } + + private function indexExists(string $table, string $indexName): bool + { + $rows = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray(); + foreach ($rows as $row) { + if (strcasecmp((string) ($row['Key_name'] ?? ''), $indexName) === 0) { + return true; + } + } + + return false; + } +} diff --git a/app/Libraries/Abdm/M3HiuGatewayClient.php b/app/Libraries/Abdm/M3HiuGatewayClient.php index b821567..911895b 100644 --- a/app/Libraries/Abdm/M3HiuGatewayClient.php +++ b/app/Libraries/Abdm/M3HiuGatewayClient.php @@ -105,15 +105,44 @@ class M3HiuGatewayClient $response['http_code'] = $httpCode; $response['error_text'] = $errorText; $response['retryable'] = $retryable; + + $gatewayRequestId = trim((string) ($response['request_id'] ?? $response['requestId'] ?? '')); + if ($gatewayRequestId === '') { + $gatewayRequestId = trim((string) ($payload['requestId'] ?? $payload['request_id'] ?? '')); + } + $response['gateway_request_id'] = $gatewayRequestId; + + $abdmConsentRequestId = $this->findFirstValueByKeys($response, [ + 'consentRequestId', + 'consent_request_id', + 'abdm_consent_request_id', + ]); + if ($abdmConsentRequestId === '') { + $abdmConsentRequestId = trim((string) ($payload['consentRequestId'] ?? '')); + } + if ($abdmConsentRequestId !== '') { + $response['abdm_consent_request_id'] = $abdmConsentRequestId; + $response['consent_request_id'] = $abdmConsentRequestId; + } + + $abdmConsentId = $this->findFirstValueByKeys($response, [ + 'consentId', + 'consent_id', + ]); + if ($abdmConsentId === '') { + $abdmConsentId = trim((string) ($payload['consentId'] ?? $payload['consent_id'] ?? '')); + } + if ($abdmConsentId !== '') { + $response['consent_id'] = $abdmConsentId; + $response['abdm_consent_artifact_id'] = $abdmConsentId; + } + if (! isset($response['request_id'])) { - $response['request_id'] = (string) ($response['requestId'] ?? $payload['requestId'] ?? $payload['request_id'] ?? ''); + $response['request_id'] = $gatewayRequestId; } if (! isset($response['transaction_id'])) { $response['transaction_id'] = (string) ($response['transactionId'] ?? $payload['transactionId'] ?? $payload['transaction_id'] ?? ''); } - if (! isset($response['consent_id'])) { - $response['consent_id'] = (string) ($response['consentId'] ?? $response['consentRequestId'] ?? $payload['consentId'] ?? $payload['consentRequestId'] ?? $payload['consent_id'] ?? ''); - } $this->writeApiLog($eventKey, $path, $payload, $response, $httpCode, $ok, $errorText, $hospitalId); @@ -211,4 +240,32 @@ class M3HiuGatewayClient 'created_at' => date('Y-m-d H:i:s'), ]); } + + private function findFirstValueByKeys(array $root, array $keys): string + { + $queue = [$root]; + while (! empty($queue)) { + $node = array_shift($queue); + if (! is_array($node)) { + continue; + } + + foreach ($keys as $key) { + if (isset($node[$key])) { + $value = trim((string) $node[$key]); + if ($value !== '') { + return $value; + } + } + } + + foreach ($node as $child) { + if (is_array($child)) { + $queue[] = $child; + } + } + } + + return ''; + } } diff --git a/app/Libraries/Abdm/M3HiuWorkflowService.php b/app/Libraries/Abdm/M3HiuWorkflowService.php index f935034..b52b55a 100644 --- a/app/Libraries/Abdm/M3HiuWorkflowService.php +++ b/app/Libraries/Abdm/M3HiuWorkflowService.php @@ -21,6 +21,35 @@ class M3HiuWorkflowService $hasWorkflowTable = $this->db->tableExists('abdm_hiu_workflows'); $normalized = $this->normalizePayload($payload); + $prepared = $this->preparePayloadForOperation($operation, $normalized); + if (($prepared['ok'] ?? 0) !== 1) { + $failedResult = [ + 'ok' => 0, + 'http_code' => (int) ($prepared['http_code'] ?? 422), + 'error_text' => (string) ($prepared['error_text'] ?? 'Validation failed'), + 'retryable' => 0, + 'request_id' => (string) ($normalized['request_id'] ?? ''), + 'transaction_id' => (string) ($normalized['transaction_id'] ?? ''), + 'consent_id' => (string) ($normalized['consent_id'] ?? ''), + 'abha_address' => (string) ($normalized['abha_address'] ?? ''), + ]; + if ($hasWorkflowTable) { + $this->upsertWorkflow( + $operation, + $normalized, + $failedResult, + 'FAILED', + 'failed', + (int) $failedResult['http_code'], + false + ); + $failedResult['workflow_persisted'] = 1; + } + + return $failedResult; + } + $dispatchPayload = (array) ($prepared['payload'] ?? []); + if ($hasWorkflowTable && $operation !== 'consent_status') { $duplicate = $this->findDuplicate($operation, $normalized); if ($duplicate !== null) { @@ -33,7 +62,7 @@ class M3HiuWorkflowService } } - $result = $this->dispatch($operation, $normalized); + $result = $this->dispatch($operation, $dispatchPayload); $httpCode = (int) ($result['http_code'] ?? 0); $ok = (int) ($result['ok'] ?? 0) === 1; @@ -42,7 +71,7 @@ class M3HiuWorkflowService $retryable = (int) ($result['retryable'] ?? 0) === 1; if ($hasWorkflowTable) { - $this->upsertWorkflow($operation, $normalized, $result, $state, $status, $httpCode, $retryable); + $this->upsertWorkflow($operation, $dispatchPayload, $result, $state, $status, $httpCode, $retryable); $result['workflow_persisted'] = 1; } else { $result['workflow_persisted'] = 0; @@ -164,6 +193,12 @@ class M3HiuWorkflowService if (! isset($normalized['consent_id']) && isset($normalized['consentRequestId'])) { $normalized['consent_id'] = (string) $normalized['consentRequestId']; } + if (! isset($normalized['abdm_consent_request_id']) && isset($normalized['consentRequestId'])) { + $normalized['abdm_consent_request_id'] = (string) $normalized['consentRequestId']; + } + if (! isset($normalized['abdm_consent_artifact_id']) && isset($normalized['consentId'])) { + $normalized['abdm_consent_artifact_id'] = (string) $normalized['consentId']; + } if (isset($normalized['abha_id']) && ! isset($normalized['abha_address'])) { $normalized['abha_address'] = (string) $normalized['abha_id']; @@ -176,6 +211,11 @@ class M3HiuWorkflowService $normalized[$k] = trim((string) $normalized[$k]); } + $normalized['abha_address'] = $this->canonicalizeAbhaAddress( + (string) ($normalized['abha_address'] ?? ''), + (string) ($normalized['abha_id'] ?? '') + ); + if ($normalized['request_id'] === '') { $normalized['request_id'] = 'REQ-HIU-' . date('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3))); } @@ -196,10 +236,371 @@ class M3HiuWorkflowService return $normalized; } + private function preparePayloadForOperation(string $operation, array $normalized): array + { + $clean = $this->stripInternalNoise($normalized); + $requestId = trim((string) ($clean['requestId'] ?? $clean['request_id'] ?? '')); + if ($requestId === '') { + $requestId = 'REQ-HIU-' . date('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3))); + } + $timestamp = trim((string) ($clean['timestamp'] ?? '')); + if ($timestamp === '') { + $timestamp = gmdate('Y-m-d\TH:i:s.000\Z'); + } + + $base = [ + 'requestId' => $requestId, + 'timestamp' => $timestamp, + ]; + $hfr = trim((string) ($clean['hfr_id'] ?? '')); + if ($hfr !== '') { + $base['hfr_id'] = $hfr; + } + + if ($operation === 'consent_request') { + $consent = $clean['consent'] ?? null; + if (! is_array($consent) || empty($consent)) { + $consent = $this->buildDefaultConsent($clean); + } + if (! is_array($consent) || empty($consent)) { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'Consent object is required for HIU consent request.', + ]; + } + + $patientId = trim((string) ($consent['patient']['id'] ?? '')); + if ($patientId === '') { + $patientId = trim((string) ($clean['abha_address'] ?? '')); + } + if (! $this->isValidAbhaAddress($patientId)) { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'Patient ABHA address must be in address format like 91510165305101@sbx.', + ]; + } + if (! isset($consent['patient']) || ! is_array($consent['patient'])) { + $consent['patient'] = []; + } + $consent['patient']['id'] = $patientId; + + $requesterName = trim((string) ($consent['requester']['name'] ?? '')); + if ($requesterName === '' || strcasecmp($requesterName, 'Hospital HMS') === 0) { + $resolvedRequesterName = $this->resolveRequesterName(); + if ($resolvedRequesterName !== '') { + if (! isset($consent['requester']) || ! is_array($consent['requester'])) { + $consent['requester'] = []; + } + $consent['requester']['name'] = $resolvedRequesterName; + } + } + + $hiuId = trim((string) ($consent['hiu']['id'] ?? '')); + if ($hiuId === '') { + $hiuId = $this->resolveHiuServiceId(); + if ($hiuId === '') { + $hiuId = trim((string) ($clean['hfr_id'] ?? '')); + } + if ($hiuId !== '') { + if (! isset($consent['hiu']) || ! is_array($consent['hiu'])) { + $consent['hiu'] = []; + } + $consent['hiu']['id'] = $hiuId; + } + } + + $requesterIdentifierValue = trim((string) ($consent['requester']['identifier']['value'] ?? '')); + if ($requesterIdentifierValue === '') { + $requesterIdentifierValue = $hiuId !== '' ? $hiuId : trim((string) ($clean['hfr_id'] ?? '')); + } + if ($requesterIdentifierValue !== '') { + if (! isset($consent['requester']) || ! is_array($consent['requester'])) { + $consent['requester'] = []; + } + if (! isset($consent['requester']['identifier']) || ! is_array($consent['requester']['identifier'])) { + $consent['requester']['identifier'] = []; + } + $consent['requester']['identifier']['system'] = 'https://abdm.gov.in/facility'; + $consent['requester']['identifier']['type'] = 'HIU'; + $consent['requester']['identifier']['value'] = $requesterIdentifierValue; + } + + return [ + 'ok' => 1, + 'payload' => $base + ['consent' => $consent], + ]; + } + + if ($operation === 'consent_status') { + $consentRequestId = trim((string) ($clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? $clean['consent_id'] ?? '')); + if ($consentRequestId === '') { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'consentRequestId is required for consent status.', + ]; + } + if ($this->isGatewayRequestIdPattern($consentRequestId)) { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'mapping_error: consentRequestId appears to be gateway request_id; use stored ABDM consentRequestId.', + ]; + } + + return [ + 'ok' => 1, + 'payload' => $base + ['consentRequestId' => $consentRequestId], + ]; + } + + if ($operation === 'consent_fetch') { + $consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? '')); + if ($consentId === '') { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'consentId is required for consent fetch.', + ]; + } + if ($this->isGatewayRequestIdPattern($consentId)) { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'mapping_error: consentId appears to be gateway 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)) { + 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; + + return [ + 'ok' => 1, + 'payload' => $base + ['hiRequest' => $hiRequest], + ]; + } + + return [ + 'ok' => 0, + 'http_code' => 400, + 'error_text' => 'Unsupported operation: ' . $operation, + ]; + } + + private function stripInternalNoise(array $payload): array + { + unset( + $payload['csrf_test_name'], + $payload['patient_id'], + $payload['abha_id'], + $payload['abhaId'] + ); + + if (isset($payload['requestId'])) { + unset($payload['request_id']); + } + if (isset($payload['transactionId'])) { + unset($payload['transaction_id']); + } + + return $payload; + } + + private function buildDefaultConsent(array $payload): array + { + $patientId = trim((string) ($payload['abha_address'] ?? '')); + if (! $this->isValidAbhaAddress($patientId)) { + return []; + } + + $requesterName = $this->resolveRequesterName(); + if ($requesterName === '') { + $requesterName = 'Hospital HMS'; + } + + $hiuId = $this->resolveHiuServiceId(); + if ($hiuId === '') { + $hiuId = trim((string) ($payload['hfr_id'] ?? '')); + } + + $consent = [ + 'purpose' => [ + 'code' => 'CAREMGT', + 'text' => 'Care Management', + 'refUri' => 'https://abdm.gov.in', + ], + 'patient' => [ + 'id' => $patientId, + ], + 'requester' => [ + 'name' => $requesterName, + ], + 'hiTypes' => ['OPConsultation', 'DiagnosticReport'], + 'permission' => [ + 'accessMode' => 'VIEW', + 'dateRange' => [ + 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-30 days')), + 'to' => gmdate('Y-m-d\T00:00:00.000\Z'), + ], + 'dataEraseAt' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('+365 days')), + 'frequency' => [ + 'unit' => 'HOUR', + 'value' => 1, + 'repeats' => 0, + ], + ], + ]; + + if ($hiuId !== '') { + $consent['hiu'] = ['id' => $hiuId]; + $consent['requester']['identifier'] = [ + 'system' => 'https://abdm.gov.in/facility', + 'type' => 'HIU', + 'value' => $hiuId, + ]; + } else { + $fallbackId = trim((string) ($payload['hfr_id'] ?? '')); + if ($fallbackId !== '') { + $consent['requester']['identifier'] = [ + 'system' => 'https://abdm.gov.in/facility', + 'type' => 'HIU', + 'value' => $fallbackId, + ]; + } + } + + return $consent; + } + + private function readSettingValue(string $key): string + { + if (! $this->db->tableExists('hospital_setting')) { + return ''; + } + + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->where('s_name', $key) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + return trim((string) ($row['s_value'] ?? '')); + } + + private function resolveRequesterName(): string + { + foreach (['ABDM_HMS_NAME', 'H_Name', 'HOSPITAL_NAME'] as $key) { + $value = $this->readSettingValue($key); + if ($value !== '') { + return $value; + } + } + + $envName = trim((string) (getenv('HOSPITAL_NAME') ?: getenv('app.name') ?: '')); + return $envName; + } + + private function resolveHiuServiceId(): string + { + if (! $this->db->tableExists('hospital_setting')) { + $envHiu = trim((string) (getenv('ABDM_HIU_ID') ?: '')); + if ($envHiu !== '') { + return $envHiu; + } + return trim((string) (getenv('ABDM_HFR_ID') ?: '')); + } + + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->whereIn('s_name', ['ABDM_HIU_ID', 'EATRIA_HIU_ID']) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $value = trim((string) ($row['s_value'] ?? '')); + if ($value !== '') { + return $value; + } + + $hfr = $this->readSettingValue('ABDM_HFR_ID'); + if ($hfr !== '') { + return $hfr; + } + + $envHiu = trim((string) (getenv('ABDM_HIU_ID') ?: '')); + if ($envHiu !== '') { + return $envHiu; + } + + return trim((string) (getenv('ABDM_HFR_ID') ?: '')); + } + + private function canonicalizeAbhaAddress(string $abhaAddress, string $abhaId): string + { + $candidate = trim($abhaAddress); + if ($candidate !== '' && $this->isValidAbhaAddress($candidate)) { + return $candidate; + } + + if ($candidate !== '') { + $digits = preg_replace('/\D+/', '', $candidate) ?? ''; + if (preg_match('/^\d{14}$/', $digits) === 1) { + $domain = trim((string) (getenv('ABDM_ABHA_ADDRESS_DEFAULT_DOMAIN') ?: 'sbx')); + return $digits . '@' . $domain; + } + } + + $abhaDigits = preg_replace('/\D+/', '', trim($abhaId)) ?? ''; + if (preg_match('/^\d{14}$/', $abhaDigits) === 1) { + $domain = trim((string) (getenv('ABDM_ABHA_ADDRESS_DEFAULT_DOMAIN') ?: 'sbx')); + return $abhaDigits . '@' . $domain; + } + + return ''; + } + + private function isValidAbhaAddress(string $value): bool + { + return preg_match('/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/', trim($value)) === 1; + } + private function resolveState(string $operation, array $result): string { $ok = (int) ($result['ok'] ?? 0) === 1; if (! $ok) { + if (str_contains(strtolower((string) ($result['error_text'] ?? '')), 'mapping_error')) { + return 'MAPPING_ERROR'; + } return 'FAILED'; } @@ -260,9 +661,17 @@ class M3HiuWorkflowService int $forceId = 0 ): void { $now = Time::now('Asia/Kolkata')->toDateTimeString(); - $requestId = trim((string) ($payload['request_id'] ?? $result['request_id'] ?? '')); - $transactionId = trim((string) ($payload['transaction_id'] ?? $result['transaction_id'] ?? '')); - $consentId = trim((string) ($payload['consent_id'] ?? $result['consent_id'] ?? '')); + $hmsRequestId = trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? '')); + if ($hmsRequestId === '') { + $hmsRequestId = trim((string) ($result['hms_request_id'] ?? '')); + } + $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'] ?? '')); + $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'] ?? '')); + if ($consentId === '' && $operation !== 'consent_fetch' && $operation !== 'hi_request') { + $consentId = $abdmConsentRequestId; + } $abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abha_id'] ?? $result['abha_address'] ?? '')); $hfrId = trim((string) ($payload['hfr_id'] ?? $result['hfr_id'] ?? '')); $hospitalId = trim((string) ($payload['hospital_id'] ?? $result['hospital_id'] ?? '')); @@ -271,7 +680,7 @@ class M3HiuWorkflowService $retryCount = 0; $id = $forceId; if ($id <= 0) { - $existing = $this->findDuplicate($operation, ['request_id' => $requestId, 'transaction_id' => $transactionId]); + $existing = $this->findDuplicate($operation, ['request_id' => $hmsRequestId, 'transaction_id' => $transactionId]); if (! empty($existing)) { $id = (int) ($existing['id'] ?? 0); $retryCount = (int) ($existing['retry_count'] ?? 0); @@ -296,7 +705,7 @@ class M3HiuWorkflowService 'hfr_id' => $hfrId !== '' ? $hfrId : 'UNKNOWN', 'abha_address' => $abhaAddress !== '' ? $abhaAddress : null, 'consent_id' => $consentId !== '' ? $consentId : null, - 'request_id' => $requestId !== '' ? $requestId : null, + 'request_id' => $hmsRequestId !== '' ? $hmsRequestId : null, 'transaction_id' => $transactionId !== '' ? $transactionId : null, 'operation' => $operation, 'workflow_state' => $state, @@ -311,6 +720,19 @@ class M3HiuWorkflowService 'updated_at' => $now, ]; + if (in_array('hms_request_id', $this->db->getFieldNames('abdm_hiu_workflows') ?? [], true)) { + $row['hms_request_id'] = $hmsRequestId !== '' ? $hmsRequestId : null; + } + if (in_array('gateway_request_id', $this->db->getFieldNames('abdm_hiu_workflows') ?? [], true)) { + $row['gateway_request_id'] = $gatewayRequestId !== '' ? $gatewayRequestId : null; + } + if (in_array('abdm_consent_request_id', $this->db->getFieldNames('abdm_hiu_workflows') ?? [], true)) { + $row['abdm_consent_request_id'] = $abdmConsentRequestId !== '' ? $abdmConsentRequestId : null; + } + if (in_array('abdm_consent_artifact_id', $this->db->getFieldNames('abdm_hiu_workflows') ?? [], true)) { + $row['abdm_consent_artifact_id'] = $consentId !== '' ? $consentId : null; + } + if (in_array($state, ['COMPLETED', 'CONSENT_FETCHED'], true)) { $row['completed_at'] = $now; } @@ -329,4 +751,9 @@ class M3HiuWorkflowService $row['created_at'] = $now; $this->db->table('abdm_hiu_workflows')->insert($row); } + + private function isGatewayRequestIdPattern(string $value): bool + { + return preg_match('/^REQ-\d{14}-[A-Za-z0-9]{6,}$/', trim($value)) === 1; + } } diff --git a/app/Views/abdm/hiu_m3.php b/app/Views/abdm/hiu_m3.php index eec48c6..b4c644f 100644 --- a/app/Views/abdm/hiu_m3.php +++ b/app/Views/abdm/hiu_m3.php @@ -44,11 +44,12 @@
-
+
-
-
+
+
+
@@ -115,6 +116,8 @@ var csrfHash = ''; var selectedPatient = null; var consentPollTimer = null; + var consentPollAttempts = 0; + var consentPollMaxAttempts = 12; var opRoute = { consent_request: '', @@ -134,6 +137,28 @@ .replace(/'/g, '''); } + function fieldEl(ids) { + for (var i = 0; i < ids.length; i++) { + var el = document.getElementById(ids[i]); + if (el) { + return el; + } + } + return null; + } + + function getFieldValue(ids) { + var el = fieldEl(ids); + return el ? (el.value || '').toString().trim() : ''; + } + + function setFieldValue(ids, value) { + var el = fieldEl(ids); + if (el) { + el.value = value; + } + } + function genReq(prefix) { var p = prefix || 'REQ-HIU'; return p + '-' + Date.now(); @@ -161,32 +186,53 @@ return ['approved', 'granted', 'active', 'linked', 'completed'].indexOf(s) !== -1; } + function isTerminalConsentState(statusText) { + var s = (statusText || '').toString().trim().toLowerCase(); + return ['approved', 'granted', 'active', 'completed', 'revoked', 'denied', 'expired', 'rejected', 'failed', 'error'].indexOf(s) !== -1; + } + + function isGatewayRequestId(v) { + return /^REQ-\d{14}-[A-Za-z0-9]{6,}$/.test((v || '').toString().trim()); + } + + function isValidConsentArtifactId(v) { + var val = (v || '').toString().trim(); + return val !== '' && !isGatewayRequestId(val); + } + function syncButtons(consentStateText) { var hasPatient = !!selectedPatient; var hasNum = hasPatient && selectedPatient.has_abha_number === 1; var hasAddr = hasPatient && selectedPatient.has_abha_address === 1; - var hasConsentRef = (document.getElementById('consent_id').value.trim() !== '' || document.getElementById('request_id').value.trim() !== ''); + var hasConsentRequestRef = (getFieldValue(['consent_request_id', 'consent_id']) !== ''); + var hasConsentArtifactRef = (getFieldValue(['consent_artifact_id', 'consent_id']) !== ''); document.getElementById('btnConsentRequest').disabled = !(hasNum && hasAddr); - document.getElementById('btnConsentStatus').disabled = !hasConsentRef; - document.getElementById('btnConsentFetch').disabled = !hasConsentRef; - document.getElementById('btnHiRequest').disabled = !(hasConsentRef && isConsentApproved(consentStateText)); + document.getElementById('btnConsentStatus').disabled = !hasConsentRequestRef; + document.getElementById('btnConsentFetch').disabled = !hasConsentArtifactRef; + document.getElementById('btnHiRequest').disabled = !(hasConsentArtifactRef && isConsentApproved(consentStateText)); updateReadinessBox(consentStateText); } function setSelectedPatient(p) { selectedPatient = p; - document.getElementById('selected_patient').value = (p.patient_name || '') + (p.patient_code ? ' (' + p.patient_code + ')' : ''); - document.getElementById('abha_address').value = p.abha_address || ''; - document.getElementById('f_abha_address').value = p.abha_address || ''; - document.getElementById('request_id').value = genReq('REQ-CONSENT'); - document.getElementById('transaction_id').value = genReq('TXN-HIU'); + setFieldValue(['selected_patient'], (p.patient_name || '') + (p.patient_code ? ' (' + p.patient_code + ')' : '')); + setFieldValue(['abha_address'], p.abha_address || ''); + setFieldValue(['f_abha_address'], p.abha_address || ''); + setFieldValue(['request_id'], genReq('REQ-CONSENT')); + setFieldValue(['transaction_id'], genReq('TXN-HIU')); var latest = p.latest_consent || {}; - var consentRef = (latest.consent_id || latest.request_id || '').toString(); - if (consentRef) { - document.getElementById('consent_id').value = consentRef; - document.getElementById('f_consent_id').value = consentRef; + var consentRequestRef = (latest.abdm_consent_request_id || '').toString(); + 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); + } else { + setFieldValue(['consent_artifact_id', 'consent_id'], ''); } var consentState = (latest.workflow_state || latest.status || 'Unknown').toString(); @@ -248,26 +294,141 @@ } var payload = Object.assign({}, custom, { - requestId: document.getElementById('request_id').value.trim(), - transactionId: document.getElementById('transaction_id').value.trim(), - abha_address: document.getElementById('abha_address').value.trim(), + requestId: getFieldValue(['request_id']), + transactionId: getFieldValue(['transaction_id']), + abha_address: getFieldValue(['abha_address']), patient_id: selectedPatient ? selectedPatient.patient_id : null }); - var consentVal = document.getElementById('consent_id').value.trim(); - if (consentVal !== '') { - payload.consentId = consentVal; - payload.consentRequestId = consentVal; + var consentRequestVal = getFieldValue(['consent_request_id', 'consent_id']); + if (consentRequestVal !== '') { + payload.consentRequestId = consentRequestVal; + payload.abdm_consent_request_id = consentRequestVal; + } + + var consentArtifactVal = getFieldValue(['consent_artifact_id', 'consent_id']); + if (consentArtifactVal !== '') { + payload.consentId = consentArtifactVal; + payload.abdm_consent_artifact_id = consentArtifactVal; } if (!payload.timestamp) { payload.timestamp = new Date().toISOString(); } + delete payload.abha_id; + delete payload.abhaId; + delete payload.consent_id; + payload[csrfName] = csrfHash; return payload; } + function isValidAbhaAddress(v) { + return /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test((v || '').toString().trim()); + } + + function buildGatewayPayload(op, raw) { + var requestId = (raw.requestId || raw.request_id || '').toString().trim(); + var timestamp = (raw.timestamp || '').toString().trim(); + var abhaAddress = (raw.abha_address || '').toString().trim(); + var base = { + requestId: requestId, + timestamp: timestamp || new Date().toISOString() + }; + + if (op === 'consent_request') { + var consent = raw.consent; + if (!consent || typeof consent !== 'object' || Array.isArray(consent) || Object.keys(consent).length === 0) { + if (!isValidAbhaAddress(abhaAddress)) { + throw new Error('Patient ABHA address must be in format like 91510165305101@sbx.'); + } + consent = { + purpose: { + code: 'CAREMGT', + text: 'Care Management', + refUri: 'https://abdm.gov.in' + }, + patient: { id: abhaAddress }, + requester: { name: 'Hospital HMS' }, + hiTypes: ['OPConsultation', 'DiagnosticReport'], + permission: { + accessMode: 'VIEW', + dateRange: { + from: new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)).toISOString(), + to: new Date().toISOString() + }, + dataEraseAt: new Date(Date.now() + (365 * 24 * 60 * 60 * 1000)).toISOString(), + frequency: { + unit: 'HOUR', + value: 1, + repeats: 0 + } + } + }; + } + + if (!consent.patient || typeof consent.patient !== 'object') { + consent.patient = {}; + } + if (!consent.patient.id && isValidAbhaAddress(abhaAddress)) { + consent.patient.id = abhaAddress; + } + if (!isValidAbhaAddress(consent.patient.id || '')) { + throw new Error('Patient ABHA address must be in format like 91510165305101@sbx.'); + } + + base.consent = consent; + return base; + } + + if (op === 'consent_status') { + var consentRequestId = (raw.abdm_consent_request_id || raw.consentRequestId || '').toString().trim(); + if (!consentRequestId) { + throw new Error('consentRequestId is required for consent status.'); + } + if (isGatewayRequestId(consentRequestId)) { + throw new Error('mapping_error: consentRequestId looks like gateway request_id (REQ-...). Use actual ABDM consentRequestId from init response.'); + } + base.consentRequestId = consentRequestId; + return base; + } + + 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)) { + throw new Error('mapping_error: consentId looks like gateway request_id (REQ-...). Use ABDM consent artifact id only.'); + } + base.consentId = consentId; + return base; + } + + if (op === 'hi_request') { + var hiRequest = raw.hiRequest; + if (!hiRequest || typeof hiRequest !== 'object' || Array.isArray(hiRequest)) { + throw new Error('hiRequest object is required for health information request.'); + } + + 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.'); + } + + base.hiRequest = hiRequest; + return base; + } + + throw new Error('Unsupported operation'); + } + function setResult(obj) { document.getElementById('result_box').textContent = JSON.stringify(obj, null, 2); } @@ -292,7 +453,13 @@ setResult({ ok: 0, message: 'Unsupported operation' }); return; } - var payload = readPayload(op); + var payload; + try { + payload = buildGatewayPayload(op, readPayload(op)); + } catch (e) { + setResult({ ok: 0, message: e.message || 'Validation failed' }); + return; + } postJson(url, payload).then(function (res) { if (!options.silent) { setResult(res); @@ -300,10 +467,15 @@ if ((res.ok || 0) === 1) { var data = res.data || {}; - var consentRef = (data.consentId || data.consentRequestId || data.consent_id || data.requestId || data.request_id || '').toString(); - if (consentRef) { - document.getElementById('consent_id').value = consentRef; - document.getElementById('f_consent_id').value = consentRef; + 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(); + if (isValidConsentArtifactId(consentArtifactRef)) { + setFieldValue(['consent_artifact_id', 'consent_id'], consentArtifactRef); } var state = (data.workflow_state || data.consent_status || data.status || res.workflow_state || '').toString(); @@ -312,9 +484,20 @@ if (op === 'consent_request') { startConsentPolling(); } - if (op === 'consent_status' && isConsentApproved(state)) { + if (op === 'consent_status' && isTerminalConsentState(state)) { stopConsentPolling(); } + } else if (op === 'consent_status') { + var errState = ((res.data || {}).workflow_state || res.workflow_state || '').toString(); + var errText = (res.error || ((res.data || {}).error_text) || '').toString().toLowerCase(); + if (isTerminalConsentState(errState) || errText.indexOf('mapping_error') !== -1) { + stopConsentPolling(); + } + } else if (op === 'consent_fetch') { + var fetchErr = (res.error || ((res.data || {}).error_text) || '').toString().toLowerCase(); + if (fetchErr.indexOf('mapping_error') !== -1) { + setFieldValue(['consent_artifact_id', 'consent_id'], ''); + } } fetchTimeline(); @@ -325,7 +508,14 @@ function startConsentPolling() { stopConsentPolling(); + consentPollAttempts = 0; consentPollTimer = setInterval(function () { + consentPollAttempts++; + if (consentPollAttempts > consentPollMaxAttempts) { + stopConsentPolling(); + setResult({ ok: 0, message: 'Consent status polling stopped after max retries. Use Refresh/Status manually or wait for callback.' }); + return; + } run('consent_status', { silent: true }); }, 15000); } @@ -335,16 +525,17 @@ clearInterval(consentPollTimer); consentPollTimer = null; } + consentPollAttempts = 0; } function fetchTimeline() { var qs = new URLSearchParams({ - hfr_id: document.getElementById('f_hfr_id').value.trim(), - consent_id: document.getElementById('f_consent_id').value.trim(), - transaction_id: document.getElementById('f_transaction_id').value.trim(), - abha_address: document.getElementById('f_abha_address').value.trim(), - date_from: document.getElementById('f_date_from').value, - date_to: document.getElementById('f_date_to').value + hfr_id: getFieldValue(['f_hfr_id']), + consent_id: getFieldValue(['f_consent_id']), + transaction_id: getFieldValue(['f_transaction_id']), + abha_address: getFieldValue(['f_abha_address']), + date_from: getFieldValue(['f_date_from']), + date_to: getFieldValue(['f_date_to']) }); fetch('?' + qs.toString(), { @@ -373,13 +564,19 @@ }); } - document.getElementById('btnPatientSearch').addEventListener('click', searchPatients); - document.getElementById('patient_search').addEventListener('keydown', function (e) { - if (e.key === 'Enter') { - e.preventDefault(); - searchPatients(); - } - }); + var btnPatientSearch = document.getElementById('btnPatientSearch'); + if (btnPatientSearch) { + btnPatientSearch.addEventListener('click', searchPatients); + } + var patientSearch = document.getElementById('patient_search'); + if (patientSearch) { + patientSearch.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + searchPatients(); + } + }); + } document.querySelectorAll('button[data-op]').forEach(function (btn) { btn.addEventListener('click', function () { @@ -387,12 +584,28 @@ }); }); - document.getElementById('consent_id').addEventListener('input', function () { - syncButtons(); - }); + var consentReqInput = fieldEl(['consent_request_id', 'consent_id']); + if (consentReqInput) { + consentReqInput.addEventListener('input', function () { + syncButtons(); + }); + } - document.getElementById('btnApplyFilter').addEventListener('click', fetchTimeline); - document.getElementById('btnRefreshTimeline').addEventListener('click', fetchTimeline); + var consentArtifactInput = fieldEl(['consent_artifact_id']); + if (consentArtifactInput) { + consentArtifactInput.addEventListener('input', function () { + syncButtons(); + }); + } + + var btnApplyFilter = document.getElementById('btnApplyFilter'); + if (btnApplyFilter) { + btnApplyFilter.addEventListener('click', fetchTimeline); + } + var btnRefreshTimeline = document.getElementById('btnRefreshTimeline'); + if (btnRefreshTimeline) { + btnRefreshTimeline.addEventListener('click', fetchTimeline); + } syncButtons(); fetchTimeline();