db = \Config\Database::connect(); $this->client = new M3HiuGatewayClient(); $this->documentRepository = new M3HiuDocumentRepository(); } public function runOperation(string $operation, array $payload): array { $hasWorkflowTable = $this->db->tableExists('abdm_hiu_workflows'); $normalized = $this->normalizePayload($payload); $retiredError = $this->retiredOperationError($operation, $normalized); if ($retiredError !== null) { if ($hasWorkflowTable) { $this->upsertWorkflow( $operation, $normalized, $retiredError, 'RETIRED', 'failed', (int) ($retiredError['http_code'] ?? 410), false ); $retiredError['workflow_persisted'] = 1; } return $retiredError; } $prepared = $this->preparePayloadForOperation($operation, $normalized); if (($prepared['ok'] ?? 0) !== 1) { $failedResult = [ '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 && ! in_array($operation, ['consent_status', 'consent_reconcile', 'data_fetch'], true)) { $duplicate = $this->findDuplicate($operation, $normalized); if ($duplicate !== null) { $dupResponse = json_decode((string) ($duplicate['response_json'] ?? ''), true); if (is_array($dupResponse) && ! empty($dupResponse)) { $dupResponse['duplicate'] = 1; $dupResponse['workflow_state'] = (string) ($duplicate['workflow_state'] ?? ''); return $dupResponse; } } } $result = $this->dispatch($operation, $dispatchPayload); $httpCode = (int) ($result['http_code'] ?? 0); $ok = (int) ($result['ok'] ?? 0) === 1; $state = $this->resolveState($operation, $result); $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) { $workflowId = $this->upsertWorkflow($operation, $persistPayload, $result, $state, $status, $httpCode, $retryable); $result['workflow_persisted'] = 1; if ($operation === 'data_fetch' && $ok) { $docPersist = $this->documentRepository->persistFromDataFetch($persistPayload, $result, $workflowId); $result['documents_persisted'] = (int) ($docPersist['saved'] ?? 0); $result['documents_updated'] = (int) ($docPersist['updated'] ?? 0); } } else { $result['workflow_persisted'] = 0; $result['workflow_warning'] = 'abdm_hiu_workflows table not found; operation executed without timeline persistence'; } return $result; } private function retiredOperationError(string $operation, array $payload): ?array { if (! in_array($operation, ['consent_fetch', 'hi_request'], true)) { return null; } return [ 'ok' => 0, 'http_code' => 410, 'error_text' => 'Operation ' . $operation . ' is retired. Use consent_reconcile and data_fetch polling only.', 'retryable' => 0, 'request_id' => (string) ($payload['request_id'] ?? ''), 'transaction_id' => (string) ($payload['transaction_id'] ?? ''), 'consent_id' => (string) ($payload['consent_id'] ?? ''), 'abha_address' => (string) ($payload['abha_address'] ?? ''), 'workflow_state' => 'RETIRED', 'replacement_ops' => ['consent_reconcile', 'data_fetch'], ]; } public function retryDue(int $limit = 20): array { if (! $this->db->tableExists('abdm_hiu_workflows')) { return ['processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0]; } $rows = $this->db->table('abdm_hiu_workflows') ->select('*') ->where('status', 'failed') ->where('is_retryable', 1) ->where('retry_count <', 3) ->groupStart() ->where('next_retry_at IS NULL', null, false) ->orWhere('next_retry_at <=', Time::now('Asia/Kolkata')->toDateTimeString()) ->groupEnd() ->orderBy('id', 'ASC') ->get($limit) ->getResultArray(); $summary = ['processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0]; foreach ($rows as $row) { $summary['processed']++; $operation = trim((string) ($row['operation'] ?? '')); $payload = json_decode((string) ($row['request_json'] ?? ''), true); if (! is_array($payload) || $operation === '') { $summary['skipped']++; continue; } if (in_array($operation, ['consent_fetch', 'hi_request'], true)) { $summary['skipped']++; continue; } $result = $this->dispatch($operation, $payload); $ok = (int) ($result['ok'] ?? 0) === 1; $state = $this->resolveState($operation, $result); $status = $ok ? 'success' : 'failed'; $retryable = (int) ($result['retryable'] ?? 0) === 1; $this->upsertWorkflow($operation, $payload, $result, $state, $status, (int) ($result['http_code'] ?? 0), $retryable, (int) ($row['id'] ?? 0)); if ($ok) { $summary['success']++; } else { $summary['failed']++; } } 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]; $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; $selectCols = ['id', 'request_id', 'transaction_id', 'consent_id', 'abha_address', 'hfr_id', 'workflow_state', 'status', 'created_at', 'updated_at']; foreach (['abdm_consent_request_id', 'abdm_consent_artifact_id', 'completed_at', 'expired_at', 'revoked_at'] as $col) { if (in_array($col, $fields, true)) { $selectCols[] = $col; } } $builder = $this->db->table('abdm_hiu_workflows') ->select(implode(', ', $selectCols)) ->where('operation', 'consent_request') ->where('status', 'success') ->whereNotIn('workflow_state', ['COMPLETED', 'EXPIRED', 'REVOKED', 'DENIED']); if (in_array('completed_at', $fields, true)) { $builder->where('completed_at IS NULL'); } if (in_array('expired_at', $fields, true)) { $builder->where('expired_at IS NULL'); } if (in_array('revoked_at', $fields, true)) { $builder->where('revoked_at IS NULL'); } // Only poll consent requests created within the last 7 days to avoid infinite polling of stale/ancient rows $cutoffDate = date('Y-m-d H:i:s', strtotime('-7 days')); $builder->where('created_at >=', $cutoffDate); $consentRows = $builder->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; $consentRowId = (int) ($row['id'] ?? 0); // Fast-check: if a child reconcile/data_fetch workflow for this request_id has already reached terminal state $terminalCheck = $this->db->table('abdm_hiu_workflows') ->select('id, workflow_state, operation') ->where('request_id', $requestId) ->whereIn('workflow_state', ['COMPLETED', 'EXPIRED', 'REVOKED', 'DENIED']) ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); if (! empty($terminalCheck)) { $termState = (string) ($terminalCheck['workflow_state'] ?? ''); $this->markConsentRequestTerminal($consentRowId, $termState); $summary['skipped']++; continue; } $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']++; $cascade = $this->fetchAllArtifactsAfterGrant($payload, $consentResult); $summary['data_updates'] += $cascade['data_updates']; $summary['failed'] += $cascade['failed']; if (! $cascade['granted']) { $summary['skipped']++; } $consentState = strtolower(trim((string) ( $consentResult['workflow_state'] ?? $consentResult['status'] ?? $consentResult['consent_status'] ?? '' ))); if (in_array($consentState, ['revoked', 'denied', 'expired'], true)) { $this->markConsentRequestTerminal($consentRowId, strtoupper($consentState)); } elseif ($cascade['granted'] && $cascade['failed'] === 0 && $cascade['data_updates'] > 0) { $this->markConsentRequestTerminal($consentRowId, 'COMPLETED'); } } else { $summary['failed']++; } } return $summary; } private function markConsentRequestTerminal(int $consentRowId, string $terminalState): void { if ($consentRowId <= 0 || ! $this->db->tableExists('abdm_hiu_workflows')) { return; } $now = Time::now('Asia/Kolkata')->toDateTimeString(); $update = [ 'workflow_state' => $terminalState, 'updated_at' => $now, ]; $fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? []; if (in_array('completed_at', $fields, true) && $terminalState === 'COMPLETED') { $update['completed_at'] = $now; } if (in_array('expired_at', $fields, true) && $terminalState === 'EXPIRED') { $update['expired_at'] = $now; } if (in_array('revoked_at', $fields, true) && in_array($terminalState, ['REVOKED', 'DENIED'], true)) { $update['revoked_at'] = $now; } $this->db->table('abdm_hiu_workflows')->where('id', $consentRowId)->update($update); } /** * Shared GRANTED-consent cascade: once a consent_reconcile call confirms * GRANTED, this fetches decrypted data for EVERY known consent artifact * under the umbrella consent_request_id -- not just one facility/HIP -- * since a single M3 HIU consent can produce one artifact PER linked HIP * facility (verified 2026-07-30: a 4-facility consent only surfaced one * facility's records when data_fetch was called for a single artifact id). * Used by both the `abdm:hiu-poll` cron (pollNatGateway) and the * patient-facing "Check Live Status" action so a manual click gets the * same full multi-facility fetch the cron would eventually perform. * * @param array $payload Same shape as the consent_reconcile payload * (request_id, transaction_id, consent_id, * abdm_consent_request_id, * abdm_consent_artifact_id, abha_address, hfr_id). * @param array $consentResult The result already returned by * runOperation('consent_reconcile', $payload). */ public function fetchAllArtifactsAfterGrant(array $payload, array $consentResult): array { $result = ['granted' => false, 'data_updates' => 0, 'failed' => 0, 'artifact_ids' => []]; $consentState = strtolower(trim((string) ( $consentResult['workflow_state'] ?? $consentResult['status'] ?? $consentResult['consent_status'] ?? '' ))); if ($consentState !== 'granted') { return $result; } $result['granted'] = true; // IMPORTANT: use the UMBRELLA consent_request_id here, not a single // resolved consent artifact id. Multi-facility ABDM M3 consents // produce ONE consent artifact PER linked HIP facility under the // same consent_request_id. Per the bridge's own contract, calling // GET /v1/hiu/data/fetch with the consent_request_id (instead of // one specific artifact id) returns ALL sessions/artifacts merged // in a single response (verified 2026-07-28: querying by // consent_request_id surfaced a sibling artifact that had NEVER // been seen by HMS before, alongside the originally-tracked one). // Falls back to a single artifact id only if no request id is // available (should not normally happen for a granted consent). $resolvedConsentId = trim((string) ( $consentResult['abdm_consent_request_id'] ?? $consentResult['consent_request_id'] ?? $consentResult['abdm_consent_artifact_id'] ?? $consentResult['consent_id'] ?? '' )); if ($resolvedConsentId === '' || $this->isGatewayRequestIdPattern($resolvedConsentId)) { return $result; } $dataPayload = $payload; $dataPayload['consent_id'] = $resolvedConsentId; $dataPayload['consent_request_id'] = $resolvedConsentId; $dataPayload['abdm_consent_artifact_id'] = $resolvedConsentId; $dataPayload['abdm_consent_request_id'] = $resolvedConsentId; $dataResult = $this->runOperation('data_fetch', $dataPayload); if ((int) ($dataResult['ok'] ?? 0) === 1) { $result['data_updates']++; } else { $result['failed']++; } // Merged-by-request-id fetch discovers NEW sibling artifacts, but // does not reliably keep returning sessions for artifacts already // established earlier (verified: an already-"received" artifact // stopped appearing in the merged list while still returning real // data when queried by its own artifact id). So remember every // artifact id ever seen for this consent request, and re-poll each // one individually every cycle too, for full coverage. $singleArtifactId = trim((string) ( $consentResult['abdm_consent_artifact_id'] ?? '' )); $fetchSessions = is_array($dataResult['sessions'] ?? null) ? $dataResult['sessions'] : []; $this->recordDiscoveredArtifacts( $resolvedConsentId, $fetchSessions, $payload['abha_address'] ?? '', $payload['hfr_id'] ?? '' ); if ($singleArtifactId !== '' && ! $this->isGatewayRequestIdPattern($singleArtifactId)) { $this->rememberArtifact($resolvedConsentId, $singleArtifactId, $payload['abha_address'] ?? '', $payload['hfr_id'] ?? ''); } foreach ($this->getKnownArtifactIds($resolvedConsentId) as $artifactId) { if ($artifactId === '' || $artifactId === $resolvedConsentId) { continue; } $result['artifact_ids'][] = $artifactId; $artifactPayload = $payload; $artifactPayload['consent_id'] = $artifactId; $artifactPayload['consent_request_id'] = $artifactId; $artifactPayload['abdm_consent_artifact_id'] = $artifactId; $artifactPayload['abdm_consent_request_id'] = $artifactId; $artifactResult = $this->runOperation('data_fetch', $artifactPayload); if ((int) ($artifactResult['ok'] ?? 0) === 1) { $result['data_updates']++; $this->markArtifactPolled($resolvedConsentId, $artifactId, (string) ($artifactResult['consent_status'] ?? '')); } else { $result['failed']++; } } return $result; } /** * Extracts and upserts every distinct artifact id found in a data_fetch * result's sessions[] array (each session's consent_id is typically * "artifactId:internalRequestId" or a bare artifact id) into the * abdm_hiu_consent_artifacts registry, so future poll cycles keep * checking each artifact individually. */ private function recordDiscoveredArtifacts(string $consentRequestId, array $sessions, string $abhaAddress, string $hfrId): void { foreach ($sessions as $session) { if (! is_array($session)) { continue; } $rawConsentId = trim((string) ($session['consent_id'] ?? '')); if ($rawConsentId === '') { continue; } $artifactId = strpos($rawConsentId, ':') !== false ? trim(explode(':', $rawConsentId)[0]) : $rawConsentId; if ($artifactId === '' || $this->isGatewayRequestIdPattern($artifactId)) { continue; } $this->rememberArtifact($consentRequestId, $artifactId, $abhaAddress, $hfrId, (string) ($session['status'] ?? '')); } } private function rememberArtifact(string $consentRequestId, string $artifactId, string $abhaAddress, string $hfrId, string $status = ''): void { if (! $this->db->tableExists('abdm_hiu_consent_artifacts') || $consentRequestId === '' || $artifactId === '') { return; } $now = Time::now('Asia/Kolkata')->toDateTimeString(); $existing = $this->db->table('abdm_hiu_consent_artifacts') ->where('consent_request_id', $consentRequestId) ->where('artifact_id', $artifactId) ->get(1) ->getRowArray(); if (! empty($existing)) { $update = ['updated_at' => $now]; if ($status !== '') { $update['last_status'] = $status; } $this->db->table('abdm_hiu_consent_artifacts')->where('id', (int) $existing['id'])->update($update); return; } $this->db->table('abdm_hiu_consent_artifacts')->insert([ 'consent_request_id' => $consentRequestId, 'artifact_id' => $artifactId, 'abha_address' => $abhaAddress !== '' ? $abhaAddress : null, 'hfr_id' => $hfrId !== '' ? $hfrId : null, 'last_status' => $status !== '' ? $status : null, 'created_at' => $now, 'updated_at' => $now, ]); } private function markArtifactPolled(string $consentRequestId, string $artifactId, string $status): void { if (! $this->db->tableExists('abdm_hiu_consent_artifacts')) { return; } $now = Time::now('Asia/Kolkata')->toDateTimeString(); $update = ['last_polled_at' => $now, 'updated_at' => $now]; if ($status !== '') { $update['last_status'] = $status; } $this->db->table('abdm_hiu_consent_artifacts') ->where('consent_request_id', $consentRequestId) ->where('artifact_id', $artifactId) ->update($update); } private function getKnownArtifactIds(string $consentRequestId): array { if (! $this->db->tableExists('abdm_hiu_consent_artifacts') || $consentRequestId === '') { return []; } $rows = $this->db->table('abdm_hiu_consent_artifacts') ->select('artifact_id') ->where('consent_request_id', $consentRequestId) ->get() ->getResultArray(); return array_values(array_unique(array_map( static fn (array $row): string => trim((string) ($row['artifact_id'] ?? '')), $rows ))); } public function listTimeline(array $filters, int $limit = 200): array { if (! $this->db->tableExists('abdm_hiu_workflows')) { return []; } $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 !== '') { $builder->where('hfr_id', $hfrId); } $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 !== '') { $builder->where('transaction_id', $transactionId); } $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 !== '') { $builder->where('created_at >=', $from . ' 00:00:00'); } $to = trim((string) ($filters['date_to'] ?? '')); if ($to !== '') { $builder->where('created_at <=', $to . ' 23:59:59'); } 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, ]; } 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) { 'consent_request' => $this->client->consentRequest($payload), 'consent_status' => $this->client->consentRequestStatus($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], }; } private function normalizePayload(array $payload): array { $normalized = $payload; $generatedRequestId = false; // Accept both legacy snake_case and guide-defined camelCase keys. if (! isset($normalized['request_id']) && isset($normalized['requestId'])) { $normalized['request_id'] = (string) $normalized['requestId']; } if (! isset($normalized['transaction_id']) && isset($normalized['transactionId'])) { $normalized['transaction_id'] = (string) $normalized['transactionId']; } if (! isset($normalized['consent_id']) && isset($normalized['consentId'])) { $normalized['consent_id'] = (string) $normalized['consentId']; } 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']; } foreach (['request_id', 'transaction_id', 'consent_id', 'abha_address', 'hfr_id'] as $k) { if (! isset($normalized[$k])) { $normalized[$k] = ''; } $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))); $generatedRequestId = true; } if (! isset($normalized['requestId']) || trim((string) $normalized['requestId']) === '') { $normalized['requestId'] = $normalized['request_id']; } if (! isset($normalized['timestamp']) || trim((string) $normalized['timestamp']) === '') { $normalized['timestamp'] = gmdate('Y-m-d\TH:i:s.000\Z'); } if ($normalized['transaction_id'] !== '' && (! isset($normalized['transactionId']) || trim((string) $normalized['transactionId']) === '')) { $normalized['transactionId'] = $normalized['transaction_id']; } if ($normalized['consent_id'] !== '' && (! isset($normalized['consentId']) || trim((string) $normalized['consentId']) === '')) { $normalized['consentId'] = $normalized['consent_id']; } $normalized['__generated_request_id'] = $generatedRequestId ? 1 : 0; return $normalized; } private function preparePayloadForOperation(string $operation, array $normalized): array { $requestIdGenerated = (int) ($normalized['__generated_request_id'] ?? 0) === 1; $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; } } // Preserve requester.identifier for bridge deployments that validate strict requester schema. $requesterIdentifierValue = trim((string) ($consent['requester']['identifier']['value'] ?? '')); if ($requesterIdentifierValue === '') { $requesterIdentifierValue = $hiuId !== '' ? $hiuId : trim((string) ($clean['hfr_id'] ?? '')); } 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; } $consent['hiTypes'] = $this->normalizeConsentHiTypes($consent['hiTypes'] ?? []); if (empty($consent['hiTypes'])) { return [ 'ok' => 0, 'http_code' => 422, 'error_text' => 'consent.hiTypes is required for consent request.', ]; } $consent = $this->clampConsentPermissionDateRangeToNow($consent); 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 === '') { $consentRequestId = $this->resolveAbdmConsentRequestIdFromWorkflow($clean); } if ($consentRequestId === '') { return [ 'ok' => 0, 'http_code' => 422, '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 a local temporary request id; use consentRequestId returned by consent request/status.', ]; } return [ 'ok' => 1, 'payload' => $base + ['consentRequestId' => $consentRequestId], ]; } if ($operation === 'consent_reconcile') { $requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? '')); if ($requestIdGenerated) { $requestIdRef = ''; } $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') { // Method 3 (per bridge gateway team, 2026-07-29): GET /v1/hiu/data/fetch // ?abha_address=... returns ALL historical decrypted records across every // past consent for the patient, instead of only the single consent/request // targeted by Methods 1/2. Callers opt into this broader lookup explicitly // (fetch_by_abha_address=1) since it is not scoped to one consent artifact. $fetchByAbha = (int) ($clean['fetch_by_abha_address'] ?? 0) === 1; if ($fetchByAbha) { $abhaAddress = trim((string) ($clean['abha_address'] ?? '')); if ($abhaAddress === '') { return [ 'ok' => 0, 'http_code' => 422, 'error_text' => 'abha_address is required to fetch all historical records by ABHA address.', ]; } return [ 'ok' => 1, 'payload' => ['abha_address' => $abhaAddress], ]; } // Method 1 (per bridge gateway team, 2026-07-29): GET /v1/hiu/data/fetch // ?request_id=... fetches data for the ONE specific consent request that // request_id belongs to -- this is the "self"/current session's own data, // distinct from Method 2 (consent_id, which can resolve to a different/ // stale session sharing the same consent artifact) and Method 3 (abha_address, // which returns unrelated historical sessions across every past consent). // Callers opt in explicitly via fetch_by_request_id=1. $fetchByRequestId = (int) ($clean['fetch_by_request_id'] ?? 0) === 1; if ($fetchByRequestId) { $requestIdOnly = trim((string) ( $clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? $clean['consent_request_id'] ?? '' )); if ($requestIdOnly === '') { return [ 'ok' => 0, 'http_code' => 422, 'error_text' => 'consent_request_id is required to fetch data by request_id (Method 1).', ]; } return [ 'ok' => 1, 'payload' => ['request_id' => $requestIdOnly], ]; } $requestFallback = ''; if (! $requestIdGenerated) { $requestFallback = trim((string) ( $clean['request_id'] ?? $clean['requestId'] ?? '' )); } $consentId = trim((string) ( $clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? $clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? $clean['consent_request_id'] ?? $requestFallback )); if ($consentId === '') { $consentId = $this->resolveAbdmConsentArtifactIdFromWorkflow($clean); } if ($consentId === '') { $consentId = $this->resolveAbdmConsentRequestIdFromWorkflow($clean); } if ($consentId === '') { return [ 'ok' => 0, 'http_code' => 422, 'error_text' => 'consent_id is required for decrypted data fetch polling. Use consent artifact id, consent request id, or original request_id after consent is GRANTED.', ]; } $query = []; $query['consent_id'] = $consentId; return [ 'ok' => 1, 'payload' => $query, ]; } 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'], $payload['__generated_request_id'] ); 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'] ?? '')); } $istTz = new \DateTimeZone('Asia/Kolkata'); $utcTz = new \DateTimeZone('UTC'); $nowIst = new \DateTimeImmutable('now', $istTz); $safeToUtc = $nowIst ->setTimezone($utcTz) ->modify('-120 seconds'); $fromUtc = $nowIst ->modify('-365 days') ->setTime(0, 0, 0) ->setTimezone($utcTz) ->format('Y-m-d\TH:i:s.000\Z'); $toUtc = $safeToUtc ->format('Y-m-d\TH:i:s.000\Z'); $eraseAtUtc = $nowIst ->modify('+365 days') ->setTime(23, 59, 59) ->setTimezone($utcTz) ->format('Y-m-d\TH:i:s.000\Z'); $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' => $fromUtc, 'to' => $toUtc, ], 'dataEraseAt' => $eraseAtUtc, '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 clampConsentPermissionDateRangeToNow(array $consent): array { if (! isset($consent['permission']) || ! is_array($consent['permission'])) { return $consent; } if (! isset($consent['permission']['dateRange']) || ! is_array($consent['permission']['dateRange'])) { return $consent; } $toRaw = trim((string) ($consent['permission']['dateRange']['to'] ?? '')); if ($toRaw === '') { return $consent; } $safeNowUtc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC'))) ->modify('-120 seconds'); $safeNowIso = $safeNowUtc->format('Y-m-d\TH:i:s.000\Z'); // IMPORTANT: always refresh dateRange.to to the current moment at actual // dispatch time, rather than only clamping it when it looks like it's in // the future. A stale 'to' (e.g. from a re-dispatched/retried payload // that reused an old consent block built hours/days earlier) would // otherwise silently pass this check forever, permanently excluding any // clinical records created after that stale cutoff (confirmed 2026-07-29: // a retried consent_request sent dateRange.to a full day behind the true // request time, per bridge team diagnostics). Always sending the freshest // possible 'to' ensures HMS requests all records up to the exact minute // of the request, per bridge API contract. try { new \DateTimeImmutable($toRaw); // validate shape only } catch (\Throwable $e) { // Fall through — invalid shape gets replaced below too. } $consent['permission']['dateRange']['to'] = $safeNowIso; return $consent; } /** * Normalize legacy/record hiTypes to HIU consent hiTypes accepted by bridge. * * @param mixed $hiTypes * @return array */ private function normalizeConsentHiTypes($hiTypes): array { if (is_string($hiTypes)) { $hiTypes = [$hiTypes]; } if (! is_array($hiTypes)) { return []; } $map = [ 'opconsultrecord' => 'OPConsultation', 'opconsultation' => 'OPConsultation', 'diagnosticreportrecord' => 'DiagnosticReport', 'diagnosticreport' => 'DiagnosticReport', 'prescriptionrecord' => 'Prescription', 'prescription' => 'Prescription', 'dischargesummaryrecord' => 'DischargeSummary', 'dischargesummary' => 'DischargeSummary', 'healthdocumentrecord' => 'HealthDocumentRecord', 'healthdocument' => 'HealthDocumentRecord', 'immunization' => 'ImmunizationRecord', 'immunizationrecord' => 'ImmunizationRecord', 'wellness' => 'WellnessRecord', 'wellnessrecord' => 'WellnessRecord', 'invoice' => 'Invoice', 'invoicerecord' => 'Invoice', ]; $normalized = []; foreach ($hiTypes as $type) { $raw = trim((string) $type); if ($raw === '') { continue; } $key = strtolower($raw); $resolved = $map[$key] ?? $raw; if (! in_array($resolved, $normalized, true)) { $normalized[] = $resolved; } } return $normalized; } 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 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')) { $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'; } $rawConsent = ''; if (isset($result['consent']) && is_array($result['consent'])) { $rawConsent = (string) ( $result['consent']['status'] ?? $result['consent']['consent_status'] ?? $result['consent']['consentStatus'] ?? $result['consent']['state'] ?? '' ); } if ($rawConsent === '' && isset($result['consentDetail']) && is_array($result['consentDetail'])) { $rawConsent = (string) ( $result['consentDetail']['status'] ?? $result['consentDetail']['consent_status'] ?? $result['consentDetail']['consentStatus'] ?? '' ); } if ($rawConsent === '' && isset($result['data']['consent']) && is_array($result['data']['consent'])) { $rawConsent = (string) ( $result['data']['consent']['status'] ?? $result['data']['consent']['consent_status'] ?? '' ); } if ($rawConsent === '') { $rawConsent = (string) ($result['consent_status'] ?? $result['consentStatus'] ?? ''); } if ($rawConsent === '' || in_array(strtolower($rawConsent), ['success', 'ok', 'failed', 'error'], true)) { $topStatus = (string) ($result['status'] ?? ''); if (! in_array(strtolower($topStatus), ['success', 'ok', 'failed', 'error', '1', '0'], true)) { $rawConsent = $topStatus; } } $statusStr = strtolower(trim($rawConsent)); if (in_array($statusStr, ['revoked', 'denied'], true)) { return 'REVOKED'; } if (in_array($statusStr, ['expired'], true)) { return 'EXPIRED'; } return match ($operation) { 'consent_request' => 'REQUESTED', 'consent_status' => ($statusStr === 'granted' || $statusStr === 'approved' || $statusStr === 'active' ? 'GRANTED' : ($statusStr === 'revoked' ? 'REVOKED' : ($statusStr === 'expired' ? 'EXPIRED' : ($statusStr === 'denied' ? 'DENIED' : 'STATUS_CHECKED')))), 'consent_fetch' => 'CONSENT_FETCHED', 'consent_callback' => ($statusStr === 'granted' || $statusStr === 'approved' || $statusStr === 'active' ? 'GRANTED' : ($statusStr === 'revoked' ? 'REVOKED' : ($statusStr === 'expired' ? 'EXPIRED' : ($statusStr === 'denied' ? 'DENIED' : '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' || $statusStr === 'approved' || $statusStr === 'active' ? 'GRANTED' : ($statusStr === 'revoked' ? 'REVOKED' : ($statusStr === 'expired' ? 'EXPIRED' : ($statusStr === 'denied' ? 'DENIED' : 'STATUS_CHECKED')))), 'data_fetch' => (($statusStr === 'completed' || isset($result['fhir_bundle']) || isset($result['bundles'])) ? 'DATA_RECEIVED' : 'DATA_PENDING'), default => 'COMPLETED', }; } private function findDuplicate(string $operation, array $payload): ?array { $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(); if (! empty($row)) { return $row; } } if ($transactionId !== '') { $row = $this->db->table('abdm_hiu_workflows') ->select('*') ->where('operation', $operation) ->where('transaction_id', $transactionId) ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); if (! empty($row)) { return $row; } } return null; } 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, array $result, string $state, string $status, int $httpCode, bool $retryable, int $forceId = 0 ): int { $now = Time::now('Asia/Kolkata')->toDateTimeString(); $hmsRequestId = $this->firstNonEmptyString([ $payload['request_id'] ?? null, $payload['requestId'] ?? null, ]); if ($hmsRequestId === '') { $hmsRequestId = $this->firstNonEmptyString([ $result['hms_request_id'] ?? null, ]); } $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, $result['consentRequestId'] ?? null, $result['consent']['consent_request_id'] ?? null, $result['consent']['consentRequestId'] ?? null, $result['consentDetail']['consent_request_id'] ?? null, $result['consentDetail']['consentRequestId'] ?? null, $result['data']['consent_request_id'] ?? null, $result['data']['consent']['consent_request_id'] ?? null, $payload['consentRequestId'] ?? null, $payload['consent_request_id'] ?? null, $payload['abdm_consent_request_id'] ?? null, $payload['consent']['consent_request_id'] ?? null, $payload['consentDetail']['consent_request_id'] ?? null, ]); $transactionId = $this->firstNonEmptyString([ $payload['transaction_id'] ?? null, $payload['transactionId'] ?? null, $result['transaction_id'] ?? null, $result['transactionId'] ?? null, ]); $consentId = $this->firstNonEmptyString([ $result['abdm_consent_artifact_id'] ?? null, $result['consent_id'] ?? null, $result['consentId'] ?? null, $result['consent']['id'] ?? null, $result['consent']['consent_id'] ?? null, $result['consent']['consentId'] ?? null, $result['consentDetail']['id'] ?? null, $result['consentDetail']['consentId'] ?? null, $result['consentDetail']['consent_id'] ?? null, $result['consent_artifact_id'] ?? null, $result['consent_artefact_id'] ?? null, $result['data']['consent']['id'] ?? null, $result['data']['consent_id'] ?? null, $result['data']['consentId'] ?? null, $payload['abdm_consent_artifact_id'] ?? null, $payload['consent_id'] ?? null, $payload['consentId'] ?? null, $payload['consent']['id'] ?? null, $payload['consentDetail']['id'] ?? null, $payload['hiRequest']['consent']['id'] ?? null, $result['hiRequest']['consent']['id'] ?? null, ]); if ($consentId === '' && $operation !== 'consent_fetch' && $operation !== 'hi_request') { $consentId = $abdmConsentRequestId; } $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; if ($id <= 0) { $existing = $this->findDuplicate($operation, ['request_id' => $hmsRequestId, 'transaction_id' => $transactionId]); if (! empty($existing)) { $id = (int) ($existing['id'] ?? 0); $retryCount = (int) ($existing['retry_count'] ?? 0); } } else { $existing = $this->db->table('abdm_hiu_workflows')->where('id', $id)->get(1)->getRowArray(); $retryCount = (int) ($existing['retry_count'] ?? 0); } if ($status === 'failed' && $retryable) { $retryCount++; } $nextRetryAt = null; if ($status === 'failed' && $retryable && $retryCount < 4) { $backoffMinutes = min(30, 1 * (2 ** max(0, $retryCount - 1))); $nextRetryAt = date('Y-m-d H:i:s', strtotime('+' . $backoffMinutes . ' minutes')); } $row = [ 'hospital_id' => $hospitalId !== '' ? $hospitalId : null, 'hfr_id' => $hfrId !== '' ? $hfrId : null, 'abha_address' => $abhaAddress !== '' ? $abhaAddress : null, 'consent_id' => $consentId !== '' ? $consentId : null, 'request_id' => $hmsRequestId !== '' ? $hmsRequestId : null, 'transaction_id' => $transactionId !== '' ? $transactionId : null, 'operation' => $operation, 'workflow_state' => $state, 'status' => $status, 'http_code' => $httpCode > 0 ? $httpCode : null, 'is_retryable' => $retryable ? 1 : 0, 'retry_count' => $retryCount, 'next_retry_at' => $nextRetryAt, 'last_error' => $errorText !== '' ? mb_substr($errorText, 0, 2000) : null, 'request_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'response_json' => (string) json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), '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; } if ($state === 'EXPIRED') { $row['expired_at'] = $now; } if ($state === 'REVOKED') { $row['revoked_at'] = $now; } if ($id > 0) { $this->db->table('abdm_hiu_workflows')->where('id', $id)->update($row); return $id; } $row['created_at'] = $now; $this->db->table('abdm_hiu_workflows')->insert($row); return (int) ($this->db->insertID() ?: 0); } private function isGatewayRequestIdPattern(string $value): bool { 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')) { 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 ''; } 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 ''; } }