From fbaa985857d8cdec7b2324c65b8b0d50d8ef176e Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Wed, 24 Jun 2026 04:50:03 +0530 Subject: [PATCH] feat(abdm): implement M2 gateway contract endpoints and live test tooling --- app/Config/Routes.php | 6 + app/Controllers/AbdmGateway.php | 603 +++++++++++++++++- app/Controllers/Opd_prescription.php | 111 +++- ...026-06-24-000612_CreateM2HmsAbdmTables.php | 217 +++++++ app/Libraries/Abdm/EAtriaBridgeConnector.php | 91 ++- app/Libraries/AbdmAuditService.php | 43 +- scripts/seed_m2_strict_test_data.php | 102 +++ scripts/test_m2_adapter_endpoints.php | 272 ++++++++ 8 files changed, 1396 insertions(+), 49 deletions(-) create mode 100644 app/Database/Migrations/2026-06-24-000612_CreateM2HmsAbdmTables.php create mode 100644 scripts/seed_m2_strict_test_data.php create mode 100644 scripts/test_m2_adapter_endpoints.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 7a02366..254bfbb 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -624,6 +624,12 @@ $routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest', ['fi $routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback'); $routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevokedCallback'); $routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback'); +// 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'); +$routes->post('api/v1/abdm/gateway/health-information/fetch', 'AbdmGateway::m2HealthInformationFetch'); +$routes->post('api/v1/abdm/gateway/consent/upsert', 'AbdmGateway::m2ConsentUpsert'); +$routes->post('api/v1/abdm/gateway/link/status', 'AbdmGateway::m2LinkStatus'); // ABDM M2 compatibility endpoints (Gateway -> HMS) $routes->post('records/discover', 'AbdmGateway::recordsDiscover'); $routes->get('records/fetch/(:any)', 'AbdmGateway::recordsFetch/$1'); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index efd357a..4f033c8 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -101,6 +101,327 @@ class AbdmGateway extends BaseController } } + // ========================================================================= + // HMS M2 Adapter Endpoints (Gateway -> HMS) + // ========================================================================= + + /** + * GET /api/v1/abdm/gateway/health + */ + public function m2GatewayHealth() + { + $authFailure = $this->validateGatewayToHmsAuth(); + if ($authFailure !== null) { + return $authFailure; + } + + return $this->response->setJSON([ + 'ok' => 1, + 'service' => 'hms-abdm-adapter', + 'time' => gmdate('Y-m-d\TH:i:s\Z'), + ]); + } + + /** + * POST /api/v1/abdm/gateway/discovery/care-contexts + */ + public function m2DiscoveryCareContexts() + { + $authFailure = $this->validateGatewayToHmsAuth(); + if ($authFailure !== null) { + return $authFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload)) { + $payload = []; + } + + $requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id'))); + $abhaId = trim((string) ($payload['abha_id'] ?? '')); + $abhaAddress = trim((string) ($payload['abha_address'] ?? '')); + + if ($abhaId === '' && $abhaAddress === '') { + return $this->response->setStatusCode(400)->setJSON([ + 'ok' => 0, + 'error' => 'MISSING_FIELD', + 'message' => 'abha_id or abha_address is required', + 'request_id' => $requestId, + ]); + } + + if (! $this->db->tableExists('patient_records')) { + return $this->response->setJSON(['ok' => 1, 'patient' => null, 'care_contexts' => []]); + } + + $abhaLookup = $abhaAddress !== '' ? $abhaAddress : $abhaId; + $rows = $this->db->table('patient_records') + ->select('patient_id, abha_id, record_type, created_at, consent_id') + ->where('abha_id', $abhaLookup) + ->where('status', 'ACTIVE') + ->orderBy('record_id', 'DESC') + ->limit(200) + ->get() + ->getResultArray(); + + if (empty($rows)) { + return $this->response->setJSON(['ok' => 1, 'patient' => null, 'care_contexts' => []]); + } + + $contexts = []; + foreach ($rows as $row) { + $patientId = (int) ($row['patient_id'] ?? 0); + $createdAt = trim((string) ($row['created_at'] ?? '')); + $datePart = $createdAt !== '' ? date('Y-m-d', strtotime($createdAt)) : date('Y-m-d'); + $recordType = strtoupper(trim((string) ($row['record_type'] ?? 'OTHER'))); + + $ccRef = $recordType . '-' . $patientId . '-' . str_replace('-', '', $datePart); + $hiType = $this->mapPatientRecordTypeToHiType($recordType); + + $contexts[$ccRef] = [ + 'referenceNumber' => $ccRef, + 'display' => $recordType . ' - ' . $datePart, + 'hiType' => $hiType, + ]; + } + + $patientDisplay = trim((string) ($payload['patient']['name'] ?? '')); + if ($patientDisplay === '') { + $patientDisplay = 'ABHA Patient'; + } + + $contextsOut = array_values($contexts); + $hiTypeSummary = ! empty($contextsOut) + ? (string) ($contextsOut[0]['hiType'] ?? 'HealthDocumentRecord') + : 'HealthDocumentRecord'; + + $this->getAuditService()->log([ + 'action' => 'discovery_records', + 'entity_type' => 'patient_records', + 'abha_id' => $abhaLookup, + 'patient_id' => (int) ($rows[0]['patient_id'] ?? 0), + 'request' => $payload, + 'response' => ['count' => count($contextsOut)], + 'outcome' => 'success', + 'transaction_id' => (string) ($payload['transaction_id'] ?? ''), + ]); + + return $this->response->setJSON([ + 'ok' => 1, + 'patient' => [ + 'referenceNumber' => $abhaLookup, + 'display' => $patientDisplay, + 'count' => count($contextsOut), + 'hiType' => $hiTypeSummary, + ], + 'care_contexts' => $contextsOut, + ]); + } + + /** + * POST /api/v1/abdm/gateway/health-information/fetch + */ + public function m2HealthInformationFetch() + { + $authFailure = $this->validateGatewayToHmsAuth(); + if ($authFailure !== null) { + return $authFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload)) { + $payload = []; + } + + $requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id'))); + $abhaId = trim((string) ($payload['abha_id'] ?? '')); + $abhaAddress = trim((string) ($payload['abha_address'] ?? '')); + $abhaLookup = $abhaAddress !== '' ? $abhaAddress : $abhaId; + + if ($abhaLookup === '') { + return $this->response->setStatusCode(400)->setJSON([ + 'ok' => 0, + 'error' => 'MISSING_FIELD', + 'message' => 'abha_id or abha_address is required', + 'request_id' => $requestId, + ]); + } + + if (! $this->db->tableExists('patient_records')) { + return $this->response->setJSON(['ok' => 1, 'entries' => []]); + } + + $requestedRefs = []; + $incomingRefs = $payload['care_context_references'] ?? []; + if (is_array($incomingRefs)) { + foreach ($incomingRefs as $ref) { + $v = trim((string) $ref); + if ($v !== '') { + $requestedRefs[] = $v; + } + } + } + + $rows = $this->db->table('patient_records') + ->select('record_id, patient_id, abha_id, consent_id, record_type, fhir_resource, created_at') + ->where('abha_id', $abhaLookup) + ->where('status', 'ACTIVE') + ->orderBy('record_id', 'DESC') + ->limit(300) + ->get() + ->getResultArray(); + + $entries = []; + foreach ($rows as $row) { + $patientId = (int) ($row['patient_id'] ?? 0); + $recordType = strtoupper(trim((string) ($row['record_type'] ?? 'OTHER'))); + $createdAt = trim((string) ($row['created_at'] ?? '')); + $datePart = $createdAt !== '' ? date('Y-m-d', strtotime($createdAt)) : date('Y-m-d'); + $ccRef = $recordType . '-' . $patientId . '-' . str_replace('-', '', $datePart); + + if (! empty($requestedRefs) && ! in_array($ccRef, $requestedRefs, true)) { + continue; + } + + $fhir = json_decode((string) ($row['fhir_resource'] ?? ''), true); + if (! is_array($fhir)) { + continue; + } + + $entries[] = [ + 'careContextReference' => $ccRef, + 'media' => 'application/fhir+json', + 'fhir' => $fhir, + ]; + } + + $this->getAuditService()->log([ + 'action' => 'fetch_record', + 'entity_type' => 'patient_records', + 'abha_id' => $abhaLookup, + 'request' => $payload, + 'response' => ['count' => count($entries)], + 'outcome' => 'success', + 'consent_id' => (string) ($payload['consent_id'] ?? ''), + 'transaction_id' => (string) ($payload['transaction_id'] ?? ''), + ]); + + return $this->response->setJSON([ + 'ok' => 1, + 'entries' => $entries, + ]); + } + + /** + * POST /api/v1/abdm/gateway/consent/upsert + */ + public function m2ConsentUpsert() + { + $authFailure = $this->validateGatewayToHmsAuth(); + if ($authFailure !== null) { + return $authFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload)) { + $payload = []; + } + + $requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id'))); + $patientId = (int) ($payload['patient_id'] ?? 0); + $abhaId = trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? '')); + $consentId = trim((string) ($payload['consent_id'] ?? '')); + $status = strtoupper(trim((string) ($payload['status'] ?? ''))); + $purpose = trim((string) ($payload['purpose'] ?? '')); + $expiresAt = trim((string) ($payload['date_range']['to'] ?? '')); + + if ($abhaId === '' || $consentId === '' || $status === '') { + return $this->response->setStatusCode(400)->setJSON([ + 'ok' => 0, + 'error' => 'MISSING_FIELD', + 'message' => 'abha_id, consent_id and status are required', + 'request_id' => $requestId, + ]); + } + + if ($this->db->tableExists('consent_logs')) { + $this->db->table('consent_logs')->insert([ + 'patient_id' => $patientId > 0 ? $patientId : 0, + 'abha_id' => $abhaId, + 'consent_id' => $consentId, + 'purpose' => $purpose !== '' ? $purpose : null, + 'status' => in_array($status, ['GRANTED', 'REVOKED', 'EXPIRED'], true) ? $status : 'EXPIRED', + 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + 'expires_at' => $expiresAt !== '' ? $expiresAt : null, + ]); + } + + return $this->response->setJSON(['ok' => 1]); + } + + /** + * POST /api/v1/abdm/gateway/link/status + */ + public function m2LinkStatus() + { + $authFailure = $this->validateGatewayToHmsAuth(); + if ($authFailure !== null) { + return $authFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload)) { + $payload = []; + } + + $requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id'))); + $abhaId = trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? '')); + $status = strtolower(trim((string) ($payload['status'] ?? ''))); + $careContextRef = trim((string) ($payload['care_context_reference'] ?? '')); + $linkedAt = trim((string) ($payload['linked_at'] ?? '')); + + if ($abhaId === '' || $careContextRef === '') { + return $this->response->setStatusCode(400)->setJSON([ + 'ok' => 0, + 'error' => 'MISSING_FIELD', + 'message' => 'abha_id and care_context_reference are required', + 'request_id' => $requestId, + ]); + } + + if ($this->db->tableExists('record_links')) { + $existing = $this->db->table('record_links') + ->where('care_context_reference', $careContextRef) + ->where('abha_id', $abhaId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $linkStatus = $status === 'linked' ? 'linked' : 'failed'; + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + if (! empty($existing)) { + $this->db->table('record_links')->where('id', (int) $existing['id'])->update([ + 'link_status' => $linkStatus, + 'linked_at' => $linkedAt !== '' ? $linkedAt : $now, + 'updated_at' => $now, + 'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + } else { + $this->db->table('record_links')->insert([ + 'abha_id' => $abhaId, + 'care_context_reference' => $careContextRef, + 'link_status' => $linkStatus, + 'linked_at' => $linkedAt !== '' ? $linkedAt : $now, + 'created_at' => $now, + 'updated_at' => $now, + 'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + } + } + + return $this->response->setJSON(['ok' => 1]); + } + public function consentRequest() { if (! $this->request->isAJAX()) { @@ -330,6 +651,17 @@ class AbdmGateway extends BaseController } } + $this->syncM2ConsentLog( + (int) ($existing['patient_id'] ?? 0), + trim((string) ($existing['abha_id'] ?? '')) !== '' + ? trim((string) ($existing['abha_id'] ?? '')) + : trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? '')), + $incomingConsentId !== '' ? $incomingConsentId : $handle, + (string) ($existing['purpose_code'] ?? ''), + $incomingStatus, + $expiresAt !== '' ? $expiresAt : ((string) ($existing['expires_at'] ?? '')) + ); + return $this->response->setJSON([ 'ok' => 1, 'consent_handle' => $handle, @@ -2018,6 +2350,15 @@ class AbdmGateway extends BaseController 'outcome' => 'success', ]); + $this->syncM2ConsentLog( + (int) ($existing['patient_id'] ?? 0), + $abhaId, + $consentHandle, + (string) ($existing['purpose_code'] ?? ''), + 'revoked', + $revokedAt + ); + return $this->response->setJSON(['ok' => 1, 'consent_handle' => $consentHandle, 'status' => 'revoked']); } @@ -2948,6 +3289,136 @@ class AbdmGateway extends BaseController return null; } + private function validateGatewayToHmsAuth() + { + $token = $this->resolveGatewayToHmsToken(); + $authHeader = trim((string) $this->request->getHeaderLine('Authorization')); + $incomingBearer = ''; + if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m) === 1) { + $incomingBearer = trim((string) ($m[1] ?? '')); + } + + if ($token === '' || $incomingBearer === '' || ! hash_equals($token, $incomingBearer)) { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error' => 'UNAUTHORIZED', + 'message' => 'Invalid Authorization bearer token', + 'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')), + ]); + } + + $signatureFailure = $this->validateGatewayToHmsSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $timestampFailure = $this->validateGatewayToHmsTimestamp(); + if ($timestampFailure !== null) { + return $timestampFailure; + } + + return null; + } + + private function validateGatewayToHmsSignature() + { + $secret = trim((string) ($this->readRuntimeSetting('GATEWAY_TO_HMS_HMAC_SECRET') ?: $this->readRuntimeSetting('EKA_WEBHOOK_SECRET'))); + if ($secret === '') { + return null; + } + + $signature = trim((string) ($this->request->getHeaderLine('X-Eka-Signature') ?: '')); + if ($signature === '') { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error' => 'UNAUTHORIZED', + 'message' => 'Missing X-Eka-Signature header', + 'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')), + ]); + } + + $signature = strtolower($signature); + if (str_starts_with($signature, 'sha256=')) { + $signature = substr($signature, 7); + } + + $rawBody = (string) $this->request->getBody(); + $expected = hash_hmac('sha256', $rawBody, $secret); + if (! hash_equals($expected, $signature)) { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error' => 'UNAUTHORIZED', + 'message' => 'Invalid request signature', + 'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')), + ]); + } + + return null; + } + + private function validateGatewayToHmsTimestamp() + { + $timestamp = trim((string) $this->request->getHeaderLine('X-Timestamp')); + if ($timestamp === '') { + return null; + } + + try { + $ts = new \DateTimeImmutable($timestamp); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $diff = abs($now->getTimestamp() - $ts->setTimezone(new \DateTimeZone('UTC'))->getTimestamp()); + if ($diff > 300) { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error' => 'UNAUTHORIZED', + 'message' => 'Request timestamp outside allowed skew', + 'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')), + ]); + } + } catch (\Throwable) { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error' => 'UNAUTHORIZED', + 'message' => 'Invalid X-Timestamp value', + 'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')), + ]); + } + + return null; + } + + private function resolveGatewayToHmsToken(): string + { + $candidates = [ + 'GATEWAY_TO_HMS_TOKEN', + 'ABDM_GATEWAY_TO_HMS_TOKEN', + 'EKA_GATEWAY_TOKEN', + 'EATRIA_BRIDGE_TOKEN', + 'ABDM_BRIDGE_TOKEN', + ]; + + foreach ($candidates as $name) { + $value = trim((string) $this->readRuntimeSetting($name)); + if ($value !== '') { + return preg_replace('/^Bearer\s+/i', '', $value); + } + } + + return ''; + } + + private function mapPatientRecordTypeToHiType(string $recordType): string + { + return match (strtoupper(trim($recordType))) { + 'OPD' => 'OPConsultRecord', + 'IPD' => 'HealthDocumentRecord', + 'LAB' => 'DiagnosticReportRecord', + 'DISCHARGE' => 'DischargeSummaryRecord', + 'MLC' => 'HealthDocumentRecord', + default => 'HealthDocumentRecord', + }; + } + private function readRuntimeSetting(string $name): string { $envValue = getenv($name); @@ -3061,13 +3532,143 @@ class AbdmGateway extends BaseController } $this->db->table('health_records')->insert($insert); + $insertId = (int) $this->db->insertID(); - return (int) $this->db->insertID(); + $this->mirrorHealthRecordToPatientRecords($data); + + return $insertId; } catch (\Throwable) { return 0; } } + /** + * Mirror an HMS health record into patient_records for M2 source-of-truth APIs. + * Fail-open by design. + * + * @param array $data + */ + private function mirrorHealthRecordToPatientRecords(array $data): void + { + try { + if (! $this->db->tableExists('patient_records')) { + return; + } + + $patientId = (int) ($data['patient_id'] ?? 0); + $abhaId = trim((string) ($data['abha_id'] ?? '')); + $fhirBundle = trim((string) ($data['fhir_bundle'] ?? '')); + + if ($patientId <= 0 || $abhaId === '' || $fhirBundle === '') { + return; + } + + $decoded = json_decode($fhirBundle, true); + if (! is_array($decoded)) { + return; + } + + $recordType = $this->resolvePatientRecordType( + (string) ($data['hi_type'] ?? ''), + (string) ($data['entity_type'] ?? '') + ); + + $expiryDate = $this->resolvePatientRecordExpiryDate( + $recordType, + (string) ($data['entity_type'] ?? ''), + (string) ($data['entity_id'] ?? '') + ); + + $this->db->table('patient_records')->insert([ + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'consent_id' => trim((string) ($data['consent_handle'] ?? '')) ?: null, + 'record_type' => $recordType, + 'fhir_resource' => json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + 'expiry_date' => $expiryDate, + 'status' => 'ACTIVE', + ]); + } catch (\Throwable) { + // Fail-open + } + } + + private function resolvePatientRecordType(string $hiType, string $entityType = ''): string + { + $v = strtolower(trim($hiType)); + if (str_contains($v, 'opconsult') || str_contains($v, 'prescription')) { + return 'OPD'; + } + if (str_contains($v, 'diagnosticreport') || $entityType === 'lab') { + return 'LAB'; + } + if (str_contains($v, 'discharge')) { + return 'DISCHARGE'; + } + if ($entityType === 'ipd') { + return 'IPD'; + } + + return 'OTHER'; + } + + private function resolvePatientRecordExpiryDate(string $recordType, string $entityType = '', string $entityId = ''): ?string + { + if ($recordType === 'MLC') { + return null; + } + + // OPD and LAB default to 3 years, IPD/DISCHARGE to 7 years. + $years = in_array($recordType, ['IPD', 'DISCHARGE'], true) ? 7 : 3; + if ($recordType === 'LAB' && strtolower(trim($entityType)) === 'ipd') { + $years = 7; + } + + return date('Y-m-d', strtotime('+' . $years . ' years')); + } + + private function syncM2ConsentLog( + int $patientId, + string $abhaId, + string $consentId, + string $purpose, + string $status, + string $expiresAt = '' + ): void { + try { + if (! $this->db->tableExists('consent_logs')) { + return; + } + + $mapped = match (strtolower(trim($status))) { + 'approved', 'granted' => 'GRANTED', + 'revoked' => 'REVOKED', + 'expired' => 'EXPIRED', + default => '', + }; + + if ($mapped === '' || $patientId <= 0 || trim($abhaId) === '' || trim($consentId) === '') { + return; + } + + $insert = [ + 'patient_id' => $patientId, + 'abha_id' => trim($abhaId), + 'consent_id' => trim($consentId), + 'purpose' => trim($purpose) !== '' ? trim($purpose) : null, + 'status' => $mapped, + 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + 'expires_at' => trim($expiresAt) !== '' ? trim($expiresAt) : null, + ]; + + $this->db->table('consent_logs')->insert($insert); + } catch (\Throwable) { + // Fail-open + } + } + /** * Update a health_record's push status and txn_id after getting a connector response. * Also creates a matching record_links row. diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index e23b8f1..0d99b38 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -5349,26 +5349,47 @@ class Opd_prescription extends BaseController try { $gatewayUrl = base_url('AbdmGateway/share_prescription_bundle'); $cookieHeader = (string) $this->request->getHeaderLine('Cookie'); + $handoffTimeoutSec = $this->resolveFhirGatewayHandoffTimeoutSec(); $client = service('curlrequest', [ - 'timeout' => 60, + 'timeout' => $handoffTimeoutSec, + 'connect_timeout' => 10, 'http_errors' => false, ]); - $response = $client->post($gatewayUrl, [ - 'headers' => array_filter([ - 'X-Requested-With' => 'XMLHttpRequest', - 'Cookie' => $cookieHeader !== '' ? $cookieHeader : null, - ]), - 'form_params' => [ - 'opd_id' => $opdId, - 'opd_session_id' => $sessionId, - 'patient_id' => $patientId, - 'abha_id' => $abhaId, - 'consent_handle' => (string) ($this->request->getPost('consent_handle') ?? ''), - csrf_token() => csrf_hash(), - ], - ]); + $response = null; + $attemptError = null; + for ($attempt = 1; $attempt <= 2; $attempt++) { + try { + $response = $client->post($gatewayUrl, [ + 'headers' => array_filter([ + 'X-Requested-With' => 'XMLHttpRequest', + 'Cookie' => $cookieHeader !== '' ? $cookieHeader : null, + ]), + 'form_params' => [ + 'opd_id' => $opdId, + 'opd_session_id' => $sessionId, + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'consent_handle' => (string) ($this->request->getPost('consent_handle') ?? ''), + csrf_token() => csrf_hash(), + ], + ]); + $attemptError = null; + break; + } catch (\Throwable $attemptException) { + $attemptError = $attemptException; + if ($attempt < 2 && $this->isTransientFhirGatewayHandoffError($attemptException)) { + log_message('warning', '[fhir_bundle_submit] transient handoff error on attempt ' . $attempt . ' for OPD#' . $opdId . ': ' . $attemptException->getMessage()); + continue; + } + throw $attemptException; + } + } + + if ($response === null && $attemptError !== null) { + throw $attemptError; + } $httpCode = (int) $response->getStatusCode(); $result = json_decode((string) $response->getBody(), true); @@ -5394,15 +5415,73 @@ class Opd_prescription extends BaseController ]); } catch (\Throwable $e) { log_message('error', '[fhir_bundle_submit] OPD#' . $opdId . ': ' . $e->getMessage()); + $isTimeout = $this->isTransientFhirGatewayHandoffError($e); + $friendly = $isTimeout + ? 'Gateway handoff timed out. Bridge is slow/unreachable right now. Please retry submit in a minute.' + : 'Gateway handoff failed: ' . $e->getMessage(); return $this->response->setJSON([ 'ok' => 0, - 'message' => 'Gateway handoff failed: ' . $e->getMessage(), + 'message' => $friendly, + 'retryable' => $isTimeout ? 1 : 0, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } } + private function resolveFhirGatewayHandoffTimeoutSec(): int + { + $timeout = 120; + + if ($this->db->tableExists('hospital_setting')) { + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->whereIn('s_name', ['ABDM_FHIR_SUBMIT_TIMEOUT_SECONDS', 'ABDM_GATEWAY_TIMEOUT_SECONDS']) + ->where('s_value !=', '') + ->orderBy('s_name', 'ASC') + ->get(1) + ->getRowArray(); + + if (! empty($row['s_value'])) { + $timeout = (int) $row['s_value']; + } + } + + if ($timeout < 30) { + $timeout = 30; + } + if ($timeout > 240) { + $timeout = 240; + } + + return $timeout; + } + + private function isTransientFhirGatewayHandoffError(\Throwable $e): bool + { + $msg = strtolower(trim($e->getMessage())); + if ($msg === '') { + return false; + } + + $needles = [ + 'timed out', + 'operation timed out', + 'curl error 28', + 'failed to connect', + 'connection reset', + 'empty reply from server', + ]; + + foreach ($needles as $needle) { + if (str_contains($msg, $needle)) { + return true; + } + } + + return false; + } + public function fhir_complaint_recode() { if (! $this->request->isAJAX()) { diff --git a/app/Database/Migrations/2026-06-24-000612_CreateM2HmsAbdmTables.php b/app/Database/Migrations/2026-06-24-000612_CreateM2HmsAbdmTables.php new file mode 100644 index 0000000..35afc06 --- /dev/null +++ b/app/Database/Migrations/2026-06-24-000612_CreateM2HmsAbdmTables.php @@ -0,0 +1,217 @@ +createPatientRecordsTable(); + $this->createConsentLogsTable(); + $this->createAuditLogsTable(); + } + + public function down(): void + { + $this->forge->dropTable('audit_logs', true); + $this->forge->dropTable('consent_logs', true); + $this->forge->dropTable('patient_records', true); + } + + private function createPatientRecordsTable(): void + { + if ($this->hasTable('patient_records')) { + return; + } + + $this->forge->addField([ + 'record_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => false, + ], + 'consent_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'record_type' => [ + 'type' => 'ENUM', + 'constraint' => ['OPD', 'IPD', 'LAB', 'DISCHARGE', 'MLC', 'OTHER'], + 'null' => false, + ], + 'fhir_resource' => [ + 'type' => 'JSON', + 'null' => false, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'expiry_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['ACTIVE', 'ARCHIVED'], + 'default' => 'ACTIVE', + 'null' => false, + ], + ]); + + $this->forge->addKey('record_id', true); + $this->forge->addKey('patient_id'); + $this->forge->addKey('abha_id'); + $this->forge->addKey('consent_id'); + $this->forge->addKey('record_type'); + $this->forge->addKey('expiry_date'); + $this->forge->addKey('status'); + $this->forge->createTable('patient_records', true); + } + + private function createConsentLogsTable(): void + { + if ($this->hasTable('consent_logs')) { + return; + } + + $this->forge->addField([ + 'log_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => false, + ], + 'consent_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => false, + ], + 'purpose' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['GRANTED', 'REVOKED', 'EXPIRED'], + 'null' => false, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('log_id', true); + $this->forge->addKey('patient_id'); + $this->forge->addKey('abha_id'); + $this->forge->addKey('consent_id'); + $this->forge->addKey('status'); + $this->forge->addKey('expires_at'); + $this->forge->createTable('consent_logs', true); + } + + private function createAuditLogsTable(): void + { + if ($this->hasTable('audit_logs')) { + return; + } + + $this->forge->addField([ + 'audit_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => false, + ], + 'action' => [ + 'type' => 'ENUM', + 'constraint' => ['DISCOVERY', 'FETCH', 'SHARE'], + 'null' => false, + ], + 'consent_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'transaction_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'timestamp' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'details' => [ + 'type' => 'TEXT', + 'null' => true, + ], + ]); + + $this->forge->addKey('audit_id', true); + $this->forge->addKey('patient_id'); + $this->forge->addKey('abha_id'); + $this->forge->addKey('action'); + $this->forge->addKey('consent_id'); + $this->forge->addKey('transaction_id'); + $this->forge->addKey('timestamp'); + $this->forge->createTable('audit_logs', true); + } + + private function hasTable(string $table): bool + { + try { + $row = $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getRowArray(); + return ! empty($row); + } catch (\Throwable) { + return false; + } + } +} diff --git a/app/Libraries/Abdm/EAtriaBridgeConnector.php b/app/Libraries/Abdm/EAtriaBridgeConnector.php index 38c7ee0..ea85ff7 100644 --- a/app/Libraries/Abdm/EAtriaBridgeConnector.php +++ b/app/Libraries/Abdm/EAtriaBridgeConnector.php @@ -33,41 +33,62 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface $this->timeoutSec = (int) ($config->eatriaBridgeTimeoutSec ?? 30); // DB (Admin Panel → ABDM Gateway Config) is the authoritative source for - // the token, URL, and HFR ID. Always read from DB so that saving a new key - // in HMS settings takes effect immediately without a server restart or - // .env change. Fall back to config/env only if DB is unavailable. + // the token, URL, and HFR ID. Initial load here; per-request refresh happens in httpCall(). + $this->refreshRuntimeSettingsFromDb(); + } + + private function refreshRuntimeSettingsFromDb(): void + { try { $db = \Config\Database::connect(); - if ($db->tableExists('hospital_setting')) { - $rows = $db->table('hospital_setting') - ->select('s_name, s_value') - ->whereIn('s_name', ['EATRIA_BRIDGE_TOKEN', 'ABDM_BRIDGE_TOKEN', 'EATRIA_BRIDGE_URL', 'ABDM_HFR_ID', 'H_HFR_ID']) - ->get() - ->getResultArray(); + if (! $db->tableExists('hospital_setting')) { + return; + } - $dbSettings = array_column($rows, 's_value', 's_name'); + $rows = $db->table('hospital_setting') + ->select('s_name, s_value') + ->whereIn('s_name', [ + 'EATRIA_BRIDGE_TOKEN', + 'ABDM_BRIDGE_TOKEN', + 'ABDM_GATEWAY_TOKEN', + 'EATRIA_BRIDGE_API_KEY', + 'EATRIA_BRIDGE_URL', + 'ABDM_HFR_ID', + 'H_HFR_ID', + 'ABDM_HOSPITAL_HFR_ID', + ]) + ->get() + ->getResultArray(); - $dbToken = (string) ($dbSettings['EATRIA_BRIDGE_TOKEN'] ?? ''); - if ($dbToken === '') { - // Backward compatibility: older setups may store the key under ABDM_BRIDGE_TOKEN. - $dbToken = (string) ($dbSettings['ABDM_BRIDGE_TOKEN'] ?? ''); - } - if ($dbToken !== '') { - $this->token = $this->sanitizeBearerToken($dbToken); - } - if (! empty($dbSettings['EATRIA_BRIDGE_URL'])) { - $this->baseUrl = rtrim(trim($dbSettings['EATRIA_BRIDGE_URL']), '/'); - } - $dbHfrId = trim((string) ($dbSettings['ABDM_HFR_ID'] ?? '')); - if ($dbHfrId === '') { - $dbHfrId = trim((string) ($dbSettings['H_HFR_ID'] ?? '')); - } - if ($dbHfrId !== '') { - $this->hfrId = $dbHfrId; - } + $dbSettings = array_column($rows, 's_value', 's_name'); + + $dbToken = trim((string) ( + $dbSettings['EATRIA_BRIDGE_TOKEN'] + ?? $dbSettings['ABDM_BRIDGE_TOKEN'] + ?? $dbSettings['ABDM_GATEWAY_TOKEN'] + ?? $dbSettings['EATRIA_BRIDGE_API_KEY'] + ?? '' + )); + if ($dbToken !== '') { + $this->token = $this->sanitizeBearerToken($dbToken); + } + + $dbUrl = trim((string) ($dbSettings['EATRIA_BRIDGE_URL'] ?? '')); + if ($dbUrl !== '') { + $this->baseUrl = rtrim($dbUrl, '/'); + } + + $dbHfrId = trim((string) ( + $dbSettings['ABDM_HFR_ID'] + ?? $dbSettings['H_HFR_ID'] + ?? $dbSettings['ABDM_HOSPITAL_HFR_ID'] + ?? '' + )); + if ($dbHfrId !== '') { + $this->hfrId = $dbHfrId; } } catch (\Throwable $e) { - // DB unavailable — continue with config/env values + log_message('warning', '[EAtriaBridge] refreshRuntimeSettingsFromDb failed: ' . $e->getMessage()); } } @@ -190,6 +211,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface */ private function httpCall(string $method, string $path, array $body = [], array $query = []): array { + // Credentials can be rotated from settings without restarting PHP workers. + // Refresh before each call to avoid stale token/HFR causing 401/403. + $this->refreshRuntimeSettingsFromDb(); + $url = $this->baseUrl . $path; if ($query !== []) { $url .= '?' . http_build_query($query); @@ -203,6 +228,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface $headers[] = 'Authorization: Bearer ' . $this->token; } + if ($this->token === '') { + log_message('warning', '[EAtriaBridge] Authorization token missing for request: ' . $method . ' ' . $url); + } + $ch = curl_init(); $curlOptions = [ CURLOPT_URL => $url, @@ -252,6 +281,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface $ok = ($httpCode >= 200 && $httpCode < 300) ? (int) ($decoded['ok'] ?? 1) : 0; $this->dbLog($method, $path, $url, $body, $httpCode, (string) $raw, $ok === 1 ? 'success' : 'error', $ok === 0 ? (string) ($decoded['message'] ?? $decoded['error_text'] ?? '') : ''); + if (($httpCode === 401 || $httpCode === 403) && ! isset($decoded['auth_hint'])) { + $decoded['auth_hint'] = 'Verify EATRIA_BRIDGE_TOKEN and ABDM_HFR_ID in hospital_setting. Current HFR=' . ($this->hfrId !== '' ? $this->hfrId : '(empty)'); + } + return array_merge($decoded, ['ok' => $ok, 'http_code' => $httpCode]); } diff --git a/app/Libraries/AbdmAuditService.php b/app/Libraries/AbdmAuditService.php index e00f419..81080c8 100644 --- a/app/Libraries/AbdmAuditService.php +++ b/app/Libraries/AbdmAuditService.php @@ -90,12 +90,12 @@ class AbdmAuditService $insert = []; - // Preferred generic schema: audit_logs(event_id, patient_id, action, timestamp, user_id) + // Preferred generic schema: audit_logs(patient_id, abha_id, action, timestamp, details) if (in_array('patient_id', $auditFields, true)) { - $insert['patient_id'] = $patientId > 0 ? $patientId : null; + $insert['patient_id'] = $patientId > 0 ? $patientId : 0; } if (in_array('action', $auditFields, true)) { - $insert['action'] = (string) ($data['action'] ?? 'unknown'); + $insert['action'] = $this->mapAuditAction((string) ($data['action'] ?? '')); } if (in_array('timestamp', $auditFields, true)) { $insert['timestamp'] = Time::now('Asia/Kolkata')->toDateTimeString(); @@ -103,6 +103,29 @@ class AbdmAuditService if (in_array('user_id', $auditFields, true)) { $insert['user_id'] = $actorUserId > 0 ? $actorUserId : null; } + if (in_array('abha_id', $auditFields, true)) { + $insert['abha_id'] = trim((string) ($data['abha_id'] ?? '')) !== '' + ? trim((string) ($data['abha_id'] ?? '')) + : 'UNKNOWN'; + } + if (in_array('consent_id', $auditFields, true)) { + $insert['consent_id'] = trim((string) ($data['consent_id'] ?? '')) ?: null; + } + if (in_array('transaction_id', $auditFields, true)) { + $insert['transaction_id'] = trim((string) ($data['transaction_id'] ?? '')) ?: null; + } + if (in_array('details', $auditFields, true)) { + $detailPayload = [ + 'raw_action' => (string) ($data['action'] ?? ''), + 'entity_type' => (string) ($data['entity_type'] ?? ''), + 'entity_id' => (string) ($data['entity_id'] ?? ''), + 'outcome' => (string) ($data['outcome'] ?? 'success'), + 'error_message' => trim((string) ($data['error_message'] ?? '')), + 'request' => $data['request'] ?? null, + 'response' => $data['response'] ?? null, + ]; + $insert['details'] = (string) json_encode($detailPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } // Extended ABDM schema fields (abdm_audit_logs) if (in_array('actor_user_id', $auditFields, true)) { @@ -185,4 +208,18 @@ class AbdmAuditService return []; } } + + private function mapAuditAction(string $action): string + { + $action = strtolower(trim($action)); + + if (str_contains($action, 'discover')) { + return 'DISCOVERY'; + } + if (str_contains($action, 'fetch')) { + return 'FETCH'; + } + + return 'SHARE'; + } } diff --git a/scripts/seed_m2_strict_test_data.php b/scripts/seed_m2_strict_test_data.php new file mode 100644 index 0000000..c393e2f --- /dev/null +++ b/scripts/seed_m2_strict_test_data.php @@ -0,0 +1,102 @@ +connect_errno) { + fwrite(STDERR, 'DB connection failed: ' . $mysqli->connect_error . PHP_EOL); + exit(1); +} + +$check = $mysqli->query("SHOW TABLES LIKE 'patient_records'"); +if (! $check || ! $check->fetch_row()) { + fwrite(STDERR, "Table patient_records not found in {$dbName}" . PHP_EOL); + exit(1); +} + +$fhirBundle = [ + 'resourceType' => 'Bundle', + 'type' => 'collection', + 'entry' => [ + [ + 'resource' => [ + 'resourceType' => 'Patient', + 'id' => 'p-' . $patientId, + 'identifier' => [ + [ + 'system' => 'https://healthid.abdm.gov.in', + 'value' => $abha, + ], + ], + 'name' => [ + [ + 'text' => 'M2 Strict Test Patient', + ], + ], + ], + ], + ], +]; + +$fhirJson = json_encode($fhirBundle, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +if (! is_string($fhirJson)) { + fwrite(STDERR, 'Failed to encode sample FHIR bundle' . PHP_EOL); + exit(1); +} + +$now = date('Y-m-d H:i:s'); +$expiry = date('Y-m-d', strtotime('+3 years')); +$consentId = 'CONS-STRICT-' . date('YmdHis'); + +$deleteStmt = $mysqli->prepare('DELETE FROM patient_records WHERE abha_id = ? AND patient_id = ?'); +$deleteStmt->bind_param('si', $abha, $patientId); +$deleteStmt->execute(); +$deleteStmt->close(); + +$insertSql = 'INSERT INTO patient_records (patient_id, abha_id, consent_id, record_type, fhir_resource, created_at, updated_at, expiry_date, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'; +$insertStmt = $mysqli->prepare($insertSql); +if (! $insertStmt) { + fwrite(STDERR, 'Prepare failed: ' . $mysqli->error . PHP_EOL); + exit(1); +} + +$recordType = 'OPD'; +$status = 'ACTIVE'; +$insertStmt->bind_param('issssssss', $patientId, $abha, $consentId, $recordType, $fhirJson, $now, $now, $expiry, $status); +$ok = $insertStmt->execute(); +if (! $ok) { + fwrite(STDERR, 'Insert failed: ' . $insertStmt->error . PHP_EOL); + $insertStmt->close(); + exit(1); +} + +$recordId = $insertStmt->insert_id; +$insertStmt->close(); +$mysqli->close(); + +echo 'Seeded patient_records row id=' . $recordId . ', abha=' . $abha . ', patient_id=' . $patientId . PHP_EOL; diff --git a/scripts/test_m2_adapter_endpoints.php b/scripts/test_m2_adapter_endpoints.php new file mode 100644 index 0000000..d75565e --- /dev/null +++ b/scripts/test_m2_adapter_endpoints.php @@ -0,0 +1,272 @@ + trim((string) ($dotEnv['database.default.hostname'] ?? getenv('database.default.hostname') ?: 'localhost')), + 'user' => trim((string) ($dotEnv['database.default.username'] ?? getenv('database.default.username') ?: 'root')), + 'pass' => trim((string) ($dotEnv['database.default.password'] ?? getenv('database.default.password') ?: '')), + 'name' => trim((string) ($dotEnv['database.default.database'] ?? getenv('database.default.database') ?: 'hms_ci4_2026')), + 'port' => (int) ($dotEnv['database.default.port'] ?? getenv('database.default.port') ?: 3306), + ]; + + $mysqli = @new mysqli($dbConfig['host'], $dbConfig['user'], $dbConfig['pass'], $dbConfig['name'], $dbConfig['port']); + if (! $mysqli->connect_errno) { + if ($token === '') { + $token = resolveRuntimeSetting( + ['GATEWAY_TO_HMS_TOKEN', 'ABDM_GATEWAY_TO_HMS_TOKEN', 'EKA_GATEWAY_TOKEN', 'EATRIA_BRIDGE_TOKEN', 'ABDM_BRIDGE_TOKEN'], + $dotEnv, + $mysqli + ); + $token = preg_replace('/^Bearer\s+/i', '', $token) ?? $token; + } + + if ($secret === '') { + $secret = resolveRuntimeSetting(['GATEWAY_TO_HMS_HMAC_SECRET', 'EKA_WEBHOOK_SECRET'], $dotEnv, $mysqli); + } + + $mysqli->close(); + } +} + +if ($token === '') { + fwrite(STDERR, "Error: missing token. Provide --token or M2_TOKEN, or use --auto-credentials.\n"); + exit(1); +} + +$nowUtc = gmdate('Y-m-d\\TH:i:s\\Z'); +$reqBase = 'REQ-' . gmdate('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3))); +$txBase = 'TX-' . gmdate('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(2))); + +$tests = [ + [ + 'name' => 'Health Check', + 'method' => 'GET', + 'path' => '/api/v1/abdm/gateway/health', + 'body' => null, + ], + [ + 'name' => 'Discovery Care Contexts', + 'method' => 'POST', + 'path' => '/api/v1/abdm/gateway/discovery/care-contexts', + 'body' => [ + 'request_id' => $reqBase . '-DISC', + 'transaction_id' => $txBase . '-DISC', + 'hospital_hfr_id' => 'IN0510000828', + 'abha_id' => $abha, + 'abha_address' => $abha, + 'patient' => [ + 'name' => 'Demo Patient', + 'gender' => 'M', + 'year_of_birth' => 1985, + ], + ], + ], + [ + 'name' => 'Health Information Fetch', + 'method' => 'POST', + 'path' => '/api/v1/abdm/gateway/health-information/fetch', + 'body' => [ + 'request_id' => $reqBase . '-FETCH', + 'transaction_id' => $txBase . '-FETCH', + 'hospital_hfr_id' => 'IN0510000828', + 'consent_id' => 'CONS-DEMO-001', + 'abha_id' => $abha, + 'abha_address' => $abha, + 'date_range' => [ + 'from' => '2026-01-01T00:00:00Z', + 'to' => $nowUtc, + ], + 'care_context_references' => [], + ], + ], + [ + 'name' => 'Consent Upsert', + 'method' => 'POST', + 'path' => '/api/v1/abdm/gateway/consent/upsert', + 'body' => [ + 'request_id' => $reqBase . '-CONS', + 'hospital_hfr_id' => 'IN0510000828', + 'patient_id' => 1, + 'consent_id' => 'CONS-DEMO-001', + 'status' => 'GRANTED', + 'abha_id' => $abha, + 'abha_address' => $abha, + 'purpose' => 'treatment', + 'date_range' => [ + 'from' => '2026-01-01T00:00:00Z', + 'to' => $nowUtc, + ], + 'hi_types' => ['OPConsultRecord', 'DiagnosticReportRecord'], + 'raw_notification' => ['source' => 'local-test-runner'], + ], + ], + [ + 'name' => 'Link Status', + 'method' => 'POST', + 'path' => '/api/v1/abdm/gateway/link/status', + 'body' => [ + 'request_id' => $reqBase . '-LINK', + 'hospital_hfr_id' => 'IN0510000828', + 'abha_id' => $abha, + 'abha_address' => $abha, + 'care_context_reference' => 'OPD-1-' . gmdate('Ymd'), + 'status' => 'linked', + 'linked_at' => $nowUtc, + 'source' => 'hip_initiated', + ], + ], +]; + +foreach ($tests as $test) { + $method = (string) $test['method']; + $path = (string) $test['path']; + $body = $test['body']; + $json = $body === null ? '' : (string) json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $requestId = 'REQ-' . gmdate('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3))); + $timestamp = gmdate('Y-m-d\\TH:i:s\\Z'); + $signature = 'sha256=' . hash_hmac('sha256', $json, $secret !== '' ? $secret : ''); + + $headers = [ + 'Authorization: Bearer ' . $token, + 'X-Request-Id: ' . $requestId, + 'X-Timestamp: ' . $timestamp, + 'X-Eka-Signature: ' . $signature, + 'Accept: application/json', + ]; + if ($method === 'POST') { + $headers[] = 'Content-Type: application/json'; + } + + $url = $baseUrl . $path; + + echo "\n============================================================\n"; + echo 'TEST: ' . $test['name'] . "\n"; + echo $method . ' ' . $url . "\n"; + + if ($dryRun) { + $escapedHeaders = ''; + foreach ($headers as $h) { + $escapedHeaders .= ' -H ' . escapeshellarg($h); + } + + if ($method === 'GET') { + echo 'curl -i -X GET ' . escapeshellarg($url) . $escapedHeaders . "\n"; + } else { + echo 'curl -i -X POST ' . escapeshellarg($url) . $escapedHeaders . ' --data ' . escapeshellarg($json) . "\n"; + } + echo "(dry-run only; request not sent)\n"; + continue; + } + + $ch = curl_init($url); + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_CUSTOMREQUEST => $method, + ]; + if ($method === 'POST') { + $opts[CURLOPT_POSTFIELDS] = $json; + } + curl_setopt_array($ch, $opts); + + $raw = curl_exec($ch); + $errno = curl_errno($ch); + $err = curl_error($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errno !== 0) { + echo 'cURL error: [' . $errno . '] ' . $err . "\n"; + continue; + } + + $decoded = json_decode((string) $raw, true); + echo 'HTTP ' . $code . "\n"; + if (is_array($decoded)) { + echo (string) json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + echo (string) $raw . "\n"; + } +} + +exit(0); + +function loadDotEnv(string $envPath): array +{ + if (! is_file($envPath)) { + return []; + } + + $result = []; + $lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + foreach ($lines as $line) { + $trimmed = trim((string) $line); + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + continue; + } + + $parts = explode('=', $trimmed, 2); + if (count($parts) !== 2) { + continue; + } + + $key = trim((string) $parts[0]); + $val = trim((string) $parts[1]); + $result[$key] = $val; + } + + return $result; +} + +function resolveRuntimeSetting(array $candidates, array $dotEnv, mysqli $mysqli): string +{ + foreach ($candidates as $name) { + $v = getenv($name); + if ($v !== false && trim((string) $v) !== '') { + return trim((string) $v); + } + + if (! empty($dotEnv[$name])) { + return trim((string) $dotEnv[$name]); + } + + $nameEsc = $mysqli->real_escape_string((string) $name); + $sql = "SELECT s_value FROM hospital_setting WHERE s_name = '" . $nameEsc . "' LIMIT 1"; + $res = $mysqli->query($sql); + if (! $res) { + continue; + } + + $row = $res->fetch_assoc(); + $dbValue = trim((string) ($row['s_value'] ?? '')); + if ($dbValue !== '') { + return $dbValue; + } + } + + return ''; +}