diff --git a/app/Commands/AbdmHiuPoll.php b/app/Commands/AbdmHiuPoll.php new file mode 100644 index 0000000..af8ba15 --- /dev/null +++ b/app/Commands/AbdmHiuPoll.php @@ -0,0 +1,37 @@ + 'Maximum recent workflow roots to poll in one run.', + ]; + + public function run(array $params) + { + $limit = (int) (CLI::getOption('limit') ?? 30); + if ($limit <= 0) { + $limit = 30; + } + + $service = new M3HiuWorkflowService(); + $summary = $service->pollNatGateway($limit); + + CLI::write('ABDM HIU NAT Poll Summary', 'yellow'); + CLI::write('Processed: ' . (int) ($summary['processed'] ?? 0)); + CLI::write('Consent updates: ' . (int) ($summary['consent_updates'] ?? 0), 'green'); + CLI::write('Data updates: ' . (int) ($summary['data_updates'] ?? 0), 'green'); + CLI::write('Failed: ' . (int) ($summary['failed'] ?? 0), ((int) ($summary['failed'] ?? 0) > 0 ? 'red' : 'green')); + CLI::write('Skipped: ' . (int) ($summary['skipped'] ?? 0)); + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 543cdc7..aefcc28 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -630,14 +630,19 @@ $routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCa $routes->get('AbdmHiu', 'AbdmHiu::index', ['filter' => $abdmPermFilter]); $routes->get('AbdmHiu/patient_lookup', 'AbdmHiu::patientLookup', ['filter' => $abdmPermFilter]); $routes->get('AbdmHiu/timeline', 'AbdmHiu::timeline', ['filter' => $abdmPermFilter]); +$routes->get('AbdmHiu/poll_summary', 'AbdmHiu::pollSummary', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/consent_request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/consent_request_status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/consent_request_fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]); $routes->post('AbdmHiu/health_information_request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]); +$routes->post('AbdmHiu/consent_reconcile', 'AbdmHiu::consentReconcile', ['filter' => $abdmPermFilter]); +$routes->post('AbdmHiu/data_fetch', 'AbdmHiu::dataFetch', ['filter' => $abdmPermFilter]); $routes->post('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/health-information/request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]); +$routes->post('api/v1/hiu/consent/status', 'AbdmHiu::consentReconcile', ['filter' => $abdmPermFilter]); +$routes->post('api/v1/hiu/data/fetch', 'AbdmHiu::dataFetch', ['filter' => $abdmPermFilter]); // HMS M2 adapter endpoints (Gateway -> HMS) - keep lowercase v1 path $routes->get('api/v1/abdm/gateway/health', 'AbdmGateway::m2GatewayHealth'); $routes->post('api/v1/abdm/gateway/discovery/care-contexts', 'AbdmGateway::m2DiscoveryCareContexts'); diff --git a/app/Controllers/AbdmHiu.php b/app/Controllers/AbdmHiu.php index edee91c..fa68bc2 100644 --- a/app/Controllers/AbdmHiu.php +++ b/app/Controllers/AbdmHiu.php @@ -43,6 +43,16 @@ class AbdmHiu extends BaseController return $this->handleOperation('hi_request', 'DATA_REQUESTED'); } + public function consentReconcile() + { + return $this->handleOperation('consent_reconcile', 'STATUS_CHECKED'); + } + + public function dataFetch() + { + return $this->handleOperation('data_fetch', 'DATA_PENDING'); + } + public function timeline() { $filters = [ @@ -62,6 +72,17 @@ class AbdmHiu extends BaseController ]); } + public function pollSummary() + { + $lookback = (int) ($this->request->getGet('lookback') ?? 180); + if ($lookback <= 0) { + $lookback = 180; + } + + $summary = $this->service->natPollSummary($lookback); + return $this->response->setJSON($summary); + } + public function patientLookup() { if (! $this->db->tableExists('patient_master')) { diff --git a/app/Libraries/Abdm/M3HiuGatewayClient.php b/app/Libraries/Abdm/M3HiuGatewayClient.php index 911895b..4e07a79 100644 --- a/app/Libraries/Abdm/M3HiuGatewayClient.php +++ b/app/Libraries/Abdm/M3HiuGatewayClient.php @@ -31,7 +31,17 @@ class M3HiuGatewayClient public function healthInformationRequest(array $payload): array { - return $this->call('/v3/hiu/health-information/request', $payload, 'health-information.request'); + return $this->call('/v1/hiu/data/request', $payload, 'health-information.request'); + } + + public function reconcileConsentStatus(array $payload): array + { + return $this->callGet('/v1/hiu/consent/status', $payload, 'consent.status.reconcile'); + } + + public function fetchDecryptedData(array $payload): array + { + return $this->callGet('/v1/hiu/data/fetch', $payload, 'data.fetch'); } private function call(string $path, array $payload, string $eventKey): array @@ -95,6 +105,10 @@ class M3HiuGatewayClient : ('HTTP ' . $httpCode . ' non-JSON response'); } + if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response ?? [])) { + $errorText = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.'; + } + $retryable = 0; if ($curlErr !== '' || $httpCode >= 500 || $httpCode === 0 || str_contains(strtolower($errorText), 'timed out')) { $retryable = 1; @@ -106,6 +120,11 @@ class M3HiuGatewayClient $response['error_text'] = $errorText; $response['retryable'] = $retryable; + if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response)) { + $response['retryable'] = 1; + $response['error_text'] = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.'; + } + $gatewayRequestId = trim((string) ($response['request_id'] ?? $response['requestId'] ?? '')); if ($gatewayRequestId === '') { $gatewayRequestId = trim((string) ($payload['requestId'] ?? $payload['request_id'] ?? '')); @@ -117,8 +136,8 @@ class M3HiuGatewayClient 'consent_request_id', 'abdm_consent_request_id', ]); - if ($abdmConsentRequestId === '') { - $abdmConsentRequestId = trim((string) ($payload['consentRequestId'] ?? '')); + if ($abdmConsentRequestId !== '' && $this->isGatewayRefId($abdmConsentRequestId)) { + $abdmConsentRequestId = ''; } if ($abdmConsentRequestId !== '') { $response['abdm_consent_request_id'] = $abdmConsentRequestId; @@ -129,8 +148,8 @@ class M3HiuGatewayClient 'consentId', 'consent_id', ]); - if ($abdmConsentId === '') { - $abdmConsentId = trim((string) ($payload['consentId'] ?? $payload['consent_id'] ?? '')); + if ($abdmConsentId !== '' && $this->isGatewayRefId($abdmConsentId)) { + $abdmConsentId = ''; } if ($abdmConsentId !== '') { $response['consent_id'] = $abdmConsentId; @@ -149,6 +168,137 @@ class M3HiuGatewayClient return $response; } + private function callGet(string $path, array $query, string $eventKey): array + { + $settings = $this->resolveRuntimeSettings(); + if (($settings['ok'] ?? 0) !== 1) { + return $settings; + } + + $token = (string) ($settings['token'] ?? ''); + $hfrId = (string) ($settings['hfr_id'] ?? ''); + $hospitalId = (string) ($settings['hospital_id'] ?? ''); + + $payloadHfrId = trim((string) ($query['hfr_id'] ?? '')); + if ($payloadHfrId !== '' && strcasecmp($payloadHfrId, $hfrId) !== 0) { + return [ + 'ok' => 0, + 'http_code' => 400, + 'error_text' => 'hfr_id mismatch: payload hfr_id does not match hospital_setting.ABDM_HFR_ID', + 'retryable' => 0, + ]; + } + + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $token, + ]; + if ($hospitalId !== '') { + $headers[] = 'X-Hospital-Id: ' . $hospitalId; + } + + $cleanQuery = []; + foreach ($query as $k => $v) { + if ($v === null) { + continue; + } + $val = trim((string) $v); + if ($val === '') { + continue; + } + $cleanQuery[$k] = $val; + } + + // Bridge polling APIs require hfr_id in query for auth scope validation. + if ($hfrId !== '') { + $cleanQuery['hfr_id'] = $hfrId; + } + + $url = $this->baseUrl . $path; + if (! empty($cleanQuery)) { + $url .= '?' . http_build_query($cleanQuery); + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeoutSec, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CUSTOMREQUEST => 'GET', + ]); + + $raw = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + + $decoded = json_decode((string) $raw, true); + $isJson = is_array($decoded); + $ok = $isJson ? (int) ($decoded['ok'] ?? ($httpCode >= 200 && $httpCode < 300 ? 1 : 0)) : (int) ($httpCode >= 200 && $httpCode < 300); + + $errorText = ''; + if ($curlErr !== '') { + $errorText = $curlErr; + } elseif ($ok !== 1) { + $errorText = $isJson + ? (string) ($decoded['message'] ?? $decoded['error_text'] ?? $decoded['error'] ?? ('HTTP ' . $httpCode)) + : ('HTTP ' . $httpCode . ' non-JSON 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.'; + } + + $retryable = 0; + if ($curlErr !== '' || $httpCode >= 500 || $httpCode === 0 || str_contains(strtolower($errorText), 'timed out')) { + $retryable = 1; + } + + $response = $isJson ? $decoded : ['raw' => (string) $raw]; + $response['ok'] = $ok; + $response['http_code'] = $httpCode; + $response['error_text'] = $errorText; + $response['retryable'] = $retryable; + + if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response)) { + $response['retryable'] = 1; + $response['error_text'] = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.'; + } + + $gatewayRequestId = trim((string) ($response['request_id'] ?? $response['requestId'] ?? $cleanQuery['request_id'] ?? '')); + $response['gateway_request_id'] = $gatewayRequestId; + + $abdmConsentRequestId = $this->findFirstValueByKeys($response, [ + 'consentRequestId', + 'consent_request_id', + 'abdm_consent_request_id', + ]); + if ($abdmConsentRequestId !== '' && ! $this->isGatewayRefId($abdmConsentRequestId)) { + $response['abdm_consent_request_id'] = $abdmConsentRequestId; + $response['consent_request_id'] = $abdmConsentRequestId; + } + + $abdmConsentId = $this->findFirstValueByKeys($response, [ + 'consentId', + 'consent_id', + 'abdm_consent_artifact_id', + ]); + if ($abdmConsentId !== '' && ! $this->isGatewayRefId($abdmConsentId)) { + $response['consent_id'] = $abdmConsentId; + $response['abdm_consent_artifact_id'] = $abdmConsentId; + } + + if (! isset($response['transaction_id'])) { + $response['transaction_id'] = (string) ($response['transactionId'] ?? $cleanQuery['transaction_id'] ?? ''); + } + + $this->writeApiLog($eventKey, $path, $cleanQuery, $response, $httpCode, $ok, $errorText, $hospitalId, 'GET'); + + return $response; + } + private function resolveRuntimeSettings(): array { if (! $this->db->tableExists('hospital_setting')) { @@ -207,7 +357,8 @@ class M3HiuGatewayClient int $httpCode, int $ok, string $errorText, - string $hospitalId + string $hospitalId, + string $httpMethod = 'POST' ): void { if (! $this->db->tableExists('abdm_api_logs')) { return; @@ -229,7 +380,7 @@ class M3HiuGatewayClient 'channel' => 'eatria_bridge', 'event_type' => 'abdm.m3.hiu.' . $eventKey, 'endpoint' => $this->baseUrl . $path, - 'http_method' => 'POST', + 'http_method' => strtoupper(trim($httpMethod)) !== '' ? strtoupper(trim($httpMethod)) : 'POST', 'entity_type' => 'abdm_hiu', 'entity_id' => $entityId, 'request_json' => (string) json_encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), @@ -268,4 +419,25 @@ class M3HiuGatewayClient return ''; } + + private function isGatewayRefId(string $value): bool + { + return preg_match('/^REQ-/i', trim($value)) === 1; + } + + private function isCloudFrontBlockedResponse(int $httpCode, $raw, array $response): bool + { + if ($httpCode !== 403) { + return false; + } + + $text = strtolower((string) $raw); + if ($text === '' && isset($response['data']['raw'])) { + $text = strtolower((string) $response['data']['raw']); + } + + return str_contains($text, 'generated by cloudfront') + || str_contains($text, 'request blocked') + || str_contains($text, 'the request could not be satisfied'); + } } diff --git a/app/Libraries/Abdm/M3HiuWorkflowService.php b/app/Libraries/Abdm/M3HiuWorkflowService.php index b52b55a..fcd4d7c 100644 --- a/app/Libraries/Abdm/M3HiuWorkflowService.php +++ b/app/Libraries/Abdm/M3HiuWorkflowService.php @@ -50,7 +50,7 @@ class M3HiuWorkflowService } $dispatchPayload = (array) ($prepared['payload'] ?? []); - if ($hasWorkflowTable && $operation !== 'consent_status') { + if ($hasWorkflowTable && ! in_array($operation, ['consent_status', 'consent_reconcile', 'data_fetch', 'hi_request'], true)) { $duplicate = $this->findDuplicate($operation, $normalized); if ($duplicate !== null) { $dupResponse = json_decode((string) ($duplicate['response_json'] ?? ''), true); @@ -70,8 +70,19 @@ class M3HiuWorkflowService $status = $ok ? 'success' : 'failed'; $retryable = (int) ($result['retryable'] ?? 0) === 1; + $persistPayload = $dispatchPayload; + foreach ([ + 'request_id', 'requestId', 'transaction_id', 'transactionId', + 'abha_address', 'consent_id', 'consentId', + 'abdm_consent_request_id', 'abdm_consent_artifact_id', 'hfr_id', + ] as $key) { + if (! array_key_exists($key, $persistPayload) && array_key_exists($key, $normalized)) { + $persistPayload[$key] = $normalized[$key]; + } + } + if ($hasWorkflowTable) { - $this->upsertWorkflow($operation, $dispatchPayload, $result, $state, $status, $httpCode, $retryable); + $this->upsertWorkflow($operation, $persistPayload, $result, $state, $status, $httpCode, $retryable); $result['workflow_persisted'] = 1; } else { $result['workflow_persisted'] = 0; @@ -129,6 +140,105 @@ class M3HiuWorkflowService return $summary; } + public function pollNatGateway(int $limit = 30): array + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return ['processed' => 0, 'consent_updates' => 0, 'data_updates' => 0, 'failed' => 0, 'skipped' => 0]; + } + + $summary = ['processed' => 0, 'consent_updates' => 0, 'data_updates' => 0, 'failed' => 0, 'skipped' => 0]; + $consentRows = $this->db->table('abdm_hiu_workflows') + ->select('*') + ->where('operation', 'consent_request') + ->where('status', 'success') + ->orderBy('id', 'DESC') + ->get($limit) + ->getResultArray(); + + $seenConsentRequests = []; + foreach ($consentRows as $row) { + $requestId = trim((string) ($row['request_id'] ?? '')); + if ($requestId === '' || isset($seenConsentRequests[$requestId])) { + $summary['skipped']++; + continue; + } + $seenConsentRequests[$requestId] = true; + $summary['processed']++; + + $payload = [ + 'request_id' => $requestId, + 'transaction_id' => trim((string) ($row['transaction_id'] ?? '')), + 'consent_id' => trim((string) ($row['consent_id'] ?? '')), + 'abdm_consent_request_id' => trim((string) ($row['abdm_consent_request_id'] ?? '')), + 'abdm_consent_artifact_id' => trim((string) ($row['abdm_consent_artifact_id'] ?? '')), + 'abha_address' => trim((string) ($row['abha_address'] ?? '')), + 'hfr_id' => $this->normalizeStoredHfrId((string) ($row['hfr_id'] ?? '')), + ]; + + $consentResult = $this->runOperation('consent_reconcile', $payload); + if ((int) ($consentResult['ok'] ?? 0) === 1) { + $summary['consent_updates']++; + + $resolvedConsentId = trim((string) ($consentResult['abdm_consent_artifact_id'] ?? $consentResult['consent_id'] ?? '')); + if ($resolvedConsentId !== '' && ! $this->hasSuccessfulHiRequest($requestId, $resolvedConsentId)) { + if (! $this->shouldAttemptHiRequest($requestId, $resolvedConsentId)) { + $summary['skipped']++; + continue; + } + + $hiPayload = $payload; + $hiPayload['consent_id'] = $resolvedConsentId; + $hiPayload['abdm_consent_artifact_id'] = $resolvedConsentId; + $hiPayload['transaction_id'] = $this->generateFreshHiTransactionId($requestId, trim((string) ($payload['transaction_id'] ?? ''))); + $hiResult = $this->runOperation('hi_request', $hiPayload); + if ((int) ($hiResult['ok'] ?? 0) !== 1) { + $summary['failed']++; + } + } + } else { + $summary['failed']++; + } + } + + $hiRows = $this->db->table('abdm_hiu_workflows') + ->select('*') + ->where('operation', 'hi_request') + ->where('status', 'success') + ->orderBy('id', 'DESC') + ->get($limit) + ->getResultArray(); + + $seenTransactions = []; + foreach ($hiRows as $row) { + $transactionId = trim((string) ($row['transaction_id'] ?? '')); + $requestId = trim((string) ($row['request_id'] ?? '')); + $key = $transactionId !== '' ? $transactionId : $requestId; + if ($key === '' || isset($seenTransactions[$key])) { + $summary['skipped']++; + continue; + } + $seenTransactions[$key] = true; + + $payload = [ + 'request_id' => $requestId, + 'transaction_id' => $transactionId, + 'consent_id' => trim((string) ($row['consent_id'] ?? '')), + 'abdm_consent_artifact_id' => trim((string) ($row['abdm_consent_artifact_id'] ?? '')), + 'abha_address' => trim((string) ($row['abha_address'] ?? '')), + 'hfr_id' => $this->normalizeStoredHfrId((string) ($row['hfr_id'] ?? '')), + ]; + + $dataResult = $this->runOperation('data_fetch', $payload); + if ((int) ($dataResult['ok'] ?? 0) === 1) { + $summary['data_updates']++; + } else { + $summary['failed']++; + } + } + + return $summary; + } + public function listTimeline(array $filters, int $limit = 200): array { if (! $this->db->tableExists('abdm_hiu_workflows')) { @@ -165,6 +275,87 @@ class M3HiuWorkflowService return $builder->get($limit)->getResultArray(); } + public function natPollSummary(int $lookbackMinutes = 180): array + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return [ + 'ok' => 0, + 'message' => 'abdm_hiu_workflows table not found', + 'last_polled_at' => null, + 'consent_updates' => 0, + 'data_updates' => 0, + 'failed' => 0, + 'pending' => 0, + 'recent_errors' => [], + ]; + } + + $fromTime = date('Y-m-d H:i:s', strtotime('-' . max(1, $lookbackMinutes) . ' minutes')); + + $lastRow = $this->db->table('abdm_hiu_workflows') + ->select('updated_at') + ->whereIn('operation', ['consent_reconcile', 'data_fetch']) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $rows = $this->db->table('abdm_hiu_workflows') + ->select('operation, status, workflow_state') + ->whereIn('operation', ['consent_reconcile', 'data_fetch']) + ->where('updated_at >=', $fromTime) + ->get() + ->getResultArray(); + + $errorRows = $this->db->table('abdm_hiu_workflows') + ->select('updated_at, operation, request_id, transaction_id, last_error') + ->whereIn('operation', ['consent_reconcile', 'data_fetch']) + ->where('status', 'failed') + ->where('updated_at >=', $fromTime) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get(8) + ->getResultArray(); + + $consentUpdates = 0; + $dataUpdates = 0; + $failed = 0; + $pending = 0; + + foreach ($rows as $row) { + $operation = trim((string) ($row['operation'] ?? '')); + $status = strtolower(trim((string) ($row['status'] ?? ''))); + $state = strtolower(trim((string) ($row['workflow_state'] ?? ''))); + + if ($status === 'failed') { + $failed++; + continue; + } + + if ($operation === 'consent_reconcile' && in_array($state, ['granted', 'status_checked'], true)) { + $consentUpdates++; + continue; + } + + if ($operation === 'data_fetch' && in_array($state, ['data_received', 'completed'], true)) { + $dataUpdates++; + continue; + } + + $pending++; + } + + return [ + 'ok' => 1, + 'last_polled_at' => $lastRow['updated_at'] ?? null, + 'lookback_minutes' => $lookbackMinutes, + 'consent_updates' => $consentUpdates, + 'data_updates' => $dataUpdates, + 'failed' => $failed, + 'pending' => $pending, + 'recent_errors' => $errorRows, + ]; + } + private function dispatch(string $operation, array $payload): array { return match ($operation) { @@ -172,6 +363,8 @@ class M3HiuWorkflowService 'consent_status' => $this->client->consentRequestStatus($payload), 'consent_fetch' => $this->client->consentRequestFetch($payload), 'hi_request' => $this->client->healthInformationRequest($payload), + 'consent_reconcile' => $this->client->reconcileConsentStatus($payload), + 'data_fetch' => $this->client->fetchDecryptedData($payload), default => ['ok' => 0, 'http_code' => 400, 'error_text' => 'Unsupported operation: ' . $operation, 'retryable' => 0], }; } @@ -335,18 +528,21 @@ class M3HiuWorkflowService if ($operation === 'consent_status') { $consentRequestId = trim((string) ($clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? $clean['consent_id'] ?? '')); + if ($consentRequestId === '') { + $consentRequestId = $this->resolveAbdmConsentRequestIdFromWorkflow($clean); + } if ($consentRequestId === '') { return [ 'ok' => 0, 'http_code' => 422, - 'error_text' => 'consentRequestId is required for consent status.', + 'error_text' => 'consentRequestId is required for consent status. Run consent request first or provide the ABDM consentRequestId from init response.', ]; } 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.', + 'error_text' => 'mapping_error: consentRequestId appears to be a local temporary request id; use consentRequestId returned by consent request/status.', ]; } @@ -369,7 +565,7 @@ class M3HiuWorkflowService return [ 'ok' => 0, 'http_code' => 422, - 'error_text' => 'mapping_error: consentId appears to be gateway request_id; use ABDM consent artifact id only.', + 'error_text' => 'mapping_error: consentId appears to be a local temporary request id; use ABDM consent artifact id only.', ]; } @@ -381,7 +577,10 @@ class M3HiuWorkflowService if ($operation === 'hi_request') { $hiRequest = $clean['hiRequest'] ?? null; - if (! is_array($hiRequest)) { + if (! is_array($hiRequest) || empty($hiRequest)) { + $hiRequest = $this->buildDefaultHiRequest($clean); + } + if (! is_array($hiRequest) || empty($hiRequest)) { return [ 'ok' => 0, 'http_code' => 422, @@ -403,9 +602,85 @@ class M3HiuWorkflowService } $hiRequest['consent']['id'] = $consentId; + $requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? '')); + $transactionId = $this->generateFreshHiTransactionId( + $requestIdRef, + trim((string) ($hiRequest['transactionId'] ?? $clean['transactionId'] ?? $clean['transaction_id'] ?? '')) + ); + $hiRequest['transactionId'] = $transactionId; + + if (! isset($hiRequest['dateRange']) || ! is_array($hiRequest['dateRange'])) { + $hiRequest['dateRange'] = [ + 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-30 days')), + 'to' => gmdate('Y-m-d\T00:00:00.000\Z'), + ]; + } + if (trim((string) ($hiRequest['dataPushUrl'] ?? '')) === '') { + $hiRequest['dataPushUrl'] = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/') . '/v3/hiu/data/push'; + } + return [ 'ok' => 1, - 'payload' => $base + ['hiRequest' => $hiRequest], + 'payload' => $base + ['transactionId' => $transactionId, 'transaction_id' => $transactionId, 'hiRequest' => $hiRequest], + ]; + } + + if ($operation === 'consent_reconcile') { + $requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? '')); + $consentRequestId = trim((string) ($clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? '')); + $consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? '')); + + if ($requestIdRef === '' && $consentRequestId === '' && $consentId === '') { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'One of request_id, consent_request_id, or consent_id is required for consent reconciliation.', + ]; + } + + $query = []; + if ($requestIdRef !== '') { + $query['request_id'] = $requestIdRef; + } + if ($consentRequestId !== '' && ! $this->isGatewayRequestIdPattern($consentRequestId)) { + $query['consent_request_id'] = $consentRequestId; + } + if ($consentId !== '' && ! $this->isGatewayRequestIdPattern($consentId)) { + $query['consent_id'] = $consentId; + } + + return [ + 'ok' => 1, + 'payload' => $query, + ]; + } + + if ($operation === 'data_fetch') { + $transactionId = trim((string) ($clean['transaction_id'] ?? $clean['transactionId'] ?? '')); + $consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? '')); + if ($consentId !== '' && $this->isGatewayRequestIdPattern($consentId)) { + $consentId = ''; + } + + if ($transactionId === '' && $consentId === '') { + return [ + 'ok' => 0, + 'http_code' => 422, + 'error_text' => 'One of transaction_id or consent_id is required for data fetch polling.', + ]; + } + + $query = []; + if ($transactionId !== '') { + $query['transaction_id'] = $transactionId; + } + if ($consentId !== '') { + $query['consent_id'] = $consentId; + } + + return [ + 'ok' => 1, + 'payload' => $query, ]; } @@ -501,6 +776,32 @@ class M3HiuWorkflowService return $consent; } + private function buildDefaultHiRequest(array $payload): array + { + $consentId = trim((string) ($payload['abdm_consent_artifact_id'] ?? $payload['consentId'] ?? $payload['consent_id'] ?? '')); + if ($consentId === '' || $this->isGatewayRequestIdPattern($consentId)) { + return []; + } + + $requestIdRef = trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? '')); + $transactionId = $this->generateFreshHiTransactionId( + $requestIdRef, + trim((string) ($payload['transactionId'] ?? $payload['transaction_id'] ?? '')) + ); + + return [ + 'consent' => [ + 'id' => $consentId, + ], + 'dateRange' => [ + 'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-30 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, + ]; + } + private function readSettingValue(string $key): string { if (! $this->db->tableExists('hospital_setting')) { @@ -530,6 +831,26 @@ class M3HiuWorkflowService return $envName; } + private function resolveHospitalHfrId(): string + { + $value = $this->readSettingValue('ABDM_HFR_ID'); + if ($value !== '') { + return $value; + } + + return trim((string) (getenv('ABDM_HFR_ID') ?: '')); + } + + private function normalizeStoredHfrId(string $value): string + { + $candidate = trim($value); + if ($candidate === '' || strcasecmp($candidate, 'UNKNOWN') === 0) { + return $this->resolveHospitalHfrId(); + } + + return $candidate; + } + private function resolveHiuServiceId(): string { if (! $this->db->tableExists('hospital_setting')) { @@ -617,6 +938,8 @@ class M3HiuWorkflowService 'consent_status' => 'STATUS_CHECKED', 'consent_fetch' => 'CONSENT_FETCHED', 'hi_request' => ($statusStr === 'completed' ? 'COMPLETED' : 'DATA_REQUESTED'), + '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', }; } @@ -650,6 +973,111 @@ class M3HiuWorkflowService return null; } + private function hasSuccessfulHiRequest(string $requestId, string $consentId): bool + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return false; + } + + $builder = $this->db->table('abdm_hiu_workflows') + ->select('id') + ->where('operation', 'hi_request') + ->where('status', 'success'); + + $builder->groupStart(); + if ($requestId !== '') { + $builder->orWhere('request_id', $requestId); + } + if ($consentId !== '') { + $builder->orWhere('consent_id', $consentId); + if (in_array('abdm_consent_artifact_id', $this->db->getFieldNames('abdm_hiu_workflows') ?? [], true)) { + $builder->orWhere('abdm_consent_artifact_id', $consentId); + } + } + $builder->groupEnd(); + + $row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray(); + return ! empty($row); + } + + private function shouldAttemptHiRequest(string $requestId, string $consentId): bool + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return true; + } + + $row = $this->latestHiRequestRow($requestId, $consentId); + if (empty($row)) { + return true; + } + + $status = strtolower(trim((string) ($row['status'] ?? ''))); + $retryable = (int) ($row['is_retryable'] ?? 0) === 1; + $retryCount = (int) ($row['retry_count'] ?? 0); + $nextRetryAt = trim((string) ($row['next_retry_at'] ?? '')); + if ($status !== 'failed' || ! $retryable) { + return true; + } + + if ($nextRetryAt === '') { + return $retryCount < 4; + } + + return strtotime($nextRetryAt) <= time(); + } + + private function latestHiRequestRow(string $requestId, string $consentId): array + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return []; + } + + $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; + $builder = $this->db->table('abdm_hiu_workflows') + ->select('id, status, is_retryable, retry_count, next_retry_at') + ->where('operation', 'hi_request'); + + $builder->groupStart(); + $hasCondition = false; + if ($requestId !== '') { + $builder->where('request_id', $requestId); + $hasCondition = true; + } + if ($consentId !== '') { + if ($hasCondition) { + $builder->orWhere('consent_id', $consentId); + } else { + $builder->where('consent_id', $consentId); + $hasCondition = true; + } + + if (in_array('abdm_consent_artifact_id', $fields, true)) { + $builder->orWhere('abdm_consent_artifact_id', $consentId); + } + } + $builder->groupEnd(); + + if (! $hasCondition) { + return []; + } + + return $builder->orderBy('id', 'DESC')->get(1)->getRowArray() ?? []; + } + + private function generateFreshHiTransactionId(string $requestId, string $currentTransactionId): string + { + return $this->generateUuidV4(); + } + + private function generateUuidV4(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + } + private function upsertWorkflow( string $operation, array $payload, @@ -666,14 +1094,14 @@ class M3HiuWorkflowService $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'] ?? '')); + $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'] ?? '')); + $consentId = trim((string) ($payload['abdm_consent_artifact_id'] ?? $payload['consent_id'] ?? $payload['consentId'] ?? $result['abdm_consent_artifact_id'] ?? $result['consent_id'] ?? $result['consentId'] ?? '')); 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'] ?? '')); + $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'] ?? '')); @@ -702,7 +1130,7 @@ class M3HiuWorkflowService $row = [ 'hospital_id' => $hospitalId !== '' ? $hospitalId : null, - 'hfr_id' => $hfrId !== '' ? $hfrId : 'UNKNOWN', + 'hfr_id' => $hfrId !== '' ? $hfrId : null, 'abha_address' => $abhaAddress !== '' ? $abhaAddress : null, 'consent_id' => $consentId !== '' ? $consentId : null, 'request_id' => $hmsRequestId !== '' ? $hmsRequestId : null, @@ -754,6 +1182,84 @@ class M3HiuWorkflowService private function isGatewayRequestIdPattern(string $value): bool { - return preg_match('/^REQ-\d{14}-[A-Za-z0-9]{6,}$/', trim($value)) === 1; + return preg_match('/^REQ-/i', trim($value)) === 1; + } + + private function resolveAbdmConsentRequestIdFromWorkflow(array $payload): string + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return ''; + } + + $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; + $hasAbdmConsentRequestId = in_array('abdm_consent_request_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 ($hasAbdmConsentRequestId): string { + if ($whereCol === null || $whereVal === '') { + return ''; + } + + $select = ['consent_id', 'response_json']; + if ($hasAbdmConsentRequestId) { + $select[] = 'abdm_consent_request_id'; + } + + $rows = $this->db->table('abdm_hiu_workflows') + ->select(implode(', ', $select)) + ->where($whereCol, $whereVal) + ->whereIn('operation', ['consent_request', 'consent_status']) + ->orderBy('id', 'DESC') + ->get(25) + ->getResultArray(); + + foreach ($rows as $row) { + $candidate = ''; + if ($hasAbdmConsentRequestId) { + $candidate = trim((string) ($row['abdm_consent_request_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_request_id'] + ?? $decoded['consent_request_id'] + ?? $decoded['consentRequestId'] + ?? '' + )); + 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 b4c644f..edd3af6 100644 --- a/app/Views/abdm/hiu_m3.php +++ b/app/Views/abdm/hiu_m3.php @@ -3,10 +3,19 @@