* EATRIA_BRIDGE_TIMEOUT = 30 */ class EAtriaBridgeConnector implements AbdmConnectorInterface { private const REQUIRED_HFR_ID = 'IN0510000870'; private string $baseUrl; private string $token; private string $hfrId; private string $bridgeHospitalId; private int $timeoutSec; /** @var array */ private array $tokenCandidates = []; /** @var array */ private array $tokenSourceByValue = []; private string $tokenSource = 'config'; public function __construct() { $config = config('AbdmConnector'); $this->baseUrl = rtrim((string) ($config->eatriaBridgeUrl ?? 'https://abdm-bridge.e-atria.in/api'), '/'); $this->token = $this->sanitizeBearerToken((string) ($config->eatriaBridgeToken ?? '')); if ($this->token !== '') { $this->tokenCandidates = [$this->token]; $this->tokenSourceByValue[$this->token] = 'config.eatriaBridgeToken'; $this->tokenSource = 'config.eatriaBridgeToken'; } $this->hfrId = self::REQUIRED_HFR_ID; $this->bridgeHospitalId = ''; $this->timeoutSec = (int) ($config->eatriaBridgeTimeoutSec ?? 30); // DB (Admin Panel → ABDM Gateway Config) is the authoritative source for // 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')) { return; } $settingFields = $db->getFieldNames('hospital_setting') ?? []; $orderCol = null; foreach (['id', 's_id'] as $candidateOrderCol) { if (in_array($candidateOrderCol, $settingFields, true)) { $orderCol = $candidateOrderCol; break; } } $rowsBuilder = $db->table('hospital_setting') ->select('s_name, s_value') ->whereIn('s_name', [ 'EATRIA_BRIDGE_TOKEN', 'EATRIA_BRIDGE_URL', 'ABDM_HFR_ID', 'H_HFR_ID', 'ABDM_HOSPITAL_HFR_ID', 'ABDM_BRIDGE_HOSPITAL_ID', 'EATRIA_BRIDGE_HOSPITAL_ID', ]); // Ensure deterministic override when duplicate setting rows exist. // Read newest-first and keep first occurrence per s_name. if ($orderCol !== null) { $rowsBuilder->orderBy($orderCol, 'DESC'); } $rows = $rowsBuilder->get()->getResultArray(); $dbSettings = []; foreach ($rows as $row) { $key = trim((string) ($row['s_name'] ?? '')); if ($key === '' || array_key_exists($key, $dbSettings)) { continue; } $dbSettings[$key] = (string) ($row['s_value'] ?? ''); } $tokenCandidates = []; $tokenSourceByValue = []; foreach (['EATRIA_BRIDGE_TOKEN'] as $tokenKey) { $rawToken = trim((string) ($dbSettings[$tokenKey] ?? '')); if ($rawToken === '') { continue; } $sanitized = $this->sanitizeBearerToken($rawToken); if ($sanitized === '' || in_array($sanitized, $tokenCandidates, true)) { continue; } $tokenCandidates[] = $sanitized; $tokenSourceByValue[$sanitized] = 'hospital_setting.' . $tokenKey; } if (! empty($tokenCandidates)) { $this->tokenCandidates = $tokenCandidates; $this->tokenSourceByValue = $tokenSourceByValue; $this->token = $tokenCandidates[0]; $this->tokenSource = $tokenSourceByValue[$this->token] ?? 'hospital_setting'; } $dbUrl = trim((string) ($dbSettings['EATRIA_BRIDGE_URL'] ?? '')); if ($dbUrl !== '') { $host = (string) (parse_url($dbUrl, PHP_URL_HOST) ?? ''); if ($host !== '' && in_array(strtolower($host), ['127.0.0.1', 'localhost', '::1'], true)) { log_message('warning', '[EAtriaBridge] Ignoring localhost EATRIA_BRIDGE_URL from hospital_setting'); } else { $this->baseUrl = rtrim($dbUrl, '/'); } } $dbHfrId = trim((string) ( $dbSettings['ABDM_HFR_ID'] ?? $dbSettings['H_HFR_ID'] ?? $dbSettings['ABDM_HOSPITAL_HFR_ID'] ?? '' )); if ($dbHfrId !== '' && strcasecmp($dbHfrId, self::REQUIRED_HFR_ID) !== 0) { log_message('warning', '[EAtriaBridge] Overriding configured HFR ID with mandated HFR ' . self::REQUIRED_HFR_ID . ' (found ' . $dbHfrId . ')'); } $this->hfrId = self::REQUIRED_HFR_ID; $dbBridgeHospitalId = trim((string) ( $dbSettings['ABDM_BRIDGE_HOSPITAL_ID'] ?? $dbSettings['EATRIA_BRIDGE_HOSPITAL_ID'] ?? '' )); if ($dbBridgeHospitalId !== '') { $this->bridgeHospitalId = $dbBridgeHospitalId; } } catch (\Throwable $e) { log_message('warning', '[EAtriaBridge] refreshRuntimeSettingsFromDb failed: ' . $e->getMessage()); } } public function getConnectorName(): string { return 'eatria_bridge'; } private function sanitizeBearerToken(string $token): string { $token = trim($token); if ($token === '') { return ''; } // Operators sometimes paste "Bearer " into settings. if (stripos($token, 'Bearer ') === 0) { $token = trim(substr($token, 7)); } $token = trim($token, " \t\n\r\0\x0B\"'"); // Operators may paste tokens with hidden whitespace/newlines. $token = (string) preg_replace('/\s+/u', '', $token); return $token; } private function normalizeBridgeGender($value): string { $v = strtoupper(trim((string) $value)); if ($v === '1' || $v === 'M' || $v === 'MALE') { return 'M'; } if ($v === '2' || $v === 'F' || $v === 'FEMALE') { return 'F'; } if ($v === '3' || $v === 'O' || $v === 'OTHER' || $v === 'OTHERS') { return 'O'; } return ''; } /** * @param array $payload * @return array */ private function redactSensitiveForLog(array $payload): array { $walk = function ($value, string $key = '') use (&$walk) { if (is_array($value)) { $out = []; foreach ($value as $k => $v) { $kk = (string) $k; $out[$k] = $walk($v, $kk); } return $out; } $lowerKey = strtolower($key); if (in_array($lowerKey, ['token', 'refresh_token', 'refreshtoken', 'authorization', 'auth', 'otp'], true)) { return '[REDACTED]'; } if (in_array($lowerKey, ['photo', 'profilephoto', 'profile_photo'], true)) { return '[REDACTED_IMAGE_BASE64]'; } if (is_string($value)) { $trimmed = trim($value); // Redact JWT-like values by shape. if (preg_match('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $trimmed)) { return '[REDACTED_JWT]'; } // Redact probable base64 blobs (e.g., profile photo) to keep logs small. if (strlen($trimmed) > 400 && preg_match('/^[A-Za-z0-9+\/=\r\n]+$/', $trimmed)) { return '[REDACTED_LARGE_BASE64]'; } } return $value; }; return $walk($payload); } // ------------------------------------------------------------------------- // Internal HTTP helper // ------------------------------------------------------------------------- /** * @param array $body * @return array */ private function post(string $path, array $body): array { return $this->httpCall('POST', $path, $body); } private function patch(string $path, array $body): array { return $this->httpCall('PATCH', $path, $body); } /** * @param array $query * @return array */ private function get(string $path, array $query = []): array { // e-Atria bridge expects hfr_id alongside Bearer auth for GET endpoints too. if ($this->hfrId !== '' && empty($query['hfr_id'])) { $query['hfr_id'] = $this->hfrId; } return $this->httpCall('GET', $path, [], $query); } /** * @param array $body * @param array $query * @return array */ 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); } $safeRequestBody = $this->redactSensitiveForLog($body); $callWithToken = function (string $tokenValue) use ($method, $url, $body, $safeRequestBody): array { $headers = [ 'Content-Type: application/json', 'Accept: application/json', ]; if ($tokenValue !== '') { $headers[] = 'Authorization: Bearer ' . $tokenValue; } if ($this->bridgeHospitalId !== '') { $headers[] = 'X-Hospital-Id: ' . $this->bridgeHospitalId; } $maskedToken = $tokenValue !== '' ? (substr($tokenValue, 0, 6) . '***' . substr($tokenValue, -4)) : '(none)'; $tokenSource = $this->tokenSourceByValue[$tokenValue] ?? $this->tokenSource; log_message('debug', '[EAtriaBridge] --> ' . $method . ' ' . $url . ' | token=' . $maskedToken . ' | token_source=' . $tokenSource . ' | body=' . json_encode($safeRequestBody)); $ch = curl_init(); $curlOptions = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeoutSec, CURLOPT_HTTPHEADER => $headers, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_CUSTOMREQUEST => $method, ]; if ($method !== 'GET' && $body !== []) { $curlOptions[CURLOPT_POSTFIELDS] = json_encode($body); } curl_setopt_array($ch, $curlOptions); $raw = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); return [ 'raw' => (string) $raw, 'http_code' => $httpCode, 'curl_error' => $curlErr, 'token' => $tokenValue, ]; }; if ($this->token === '') { log_message('error', '[EAtriaBridge] Authorization token missing for request: ' . $method . ' ' . $url); return [ 'ok' => 0, 'http_code' => 0, 'error_text' => 'Missing EATRIA_BRIDGE_TOKEN in hospital_setting', ]; } if ($this->tokenSource !== 'hospital_setting.EATRIA_BRIDGE_TOKEN') { $tokenSha256 = hash('sha256', $this->token); log_message('error', '[EAtriaBridge] Rejecting non-hospital-setting token source=' . $this->tokenSource . ' for request: ' . $method . ' ' . $url); return [ 'ok' => 0, 'http_code' => 0, 'error_text' => 'Gateway auth token must come from hospital_setting.EATRIA_BRIDGE_TOKEN', 'auth_debug' => [ 'token_source' => $this->tokenSource, 'token_sha12' => substr($tokenSha256, 0, 12), 'token_sha256' => $tokenSha256, 'hfr_id' => $this->hfrId, 'base_url' => $this->baseUrl, ], ]; } $attempt = $callWithToken($this->token); $raw = (string) ($attempt['raw'] ?? ''); $httpCode = (int) ($attempt['http_code'] ?? 0); $curlErr = (string) ($attempt['curl_error'] ?? ''); // Retry with alternate token candidates when gateway reports auth failure. if ($curlErr === '' && in_array($httpCode, [401, 403], true) && count($this->tokenCandidates) > 1) { foreach ($this->tokenCandidates as $candidateToken) { if ($candidateToken === (string) ($attempt['token'] ?? '')) { continue; } log_message('warning', '[EAtriaBridge] auth failed with token source=' . ($this->tokenSourceByValue[(string) ($attempt['token'] ?? '')] ?? $this->tokenSource) . '; retrying with alternate token source=' . ($this->tokenSourceByValue[$candidateToken] ?? 'unknown')); $retryAttempt = $callWithToken($candidateToken); $retryHttpCode = (int) ($retryAttempt['http_code'] ?? 0); $retryCurlErr = (string) ($retryAttempt['curl_error'] ?? ''); if ($retryCurlErr === '' && ! in_array($retryHttpCode, [401, 403], true)) { $this->token = $candidateToken; $this->tokenSource = $this->tokenSourceByValue[$candidateToken] ?? $this->tokenSource; $attempt = $retryAttempt; $raw = (string) ($retryAttempt['raw'] ?? ''); $httpCode = $retryHttpCode; $curlErr = $retryCurlErr; break; } } } $safeRawForLog = (string) $raw; $decodedRawForLog = json_decode((string) $raw, true); if (is_array($decodedRawForLog)) { $safeRawForLog = (string) json_encode($this->redactSensitiveForLog($decodedRawForLog), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } log_message('debug', '[EAtriaBridge] <-- HTTP ' . $httpCode . ' | raw=' . $safeRawForLog); $tokenSha256 = $this->token !== '' ? hash('sha256', $this->token) : ''; $authDebug = [ 'token_source' => $this->tokenSource, 'token_sha12' => $tokenSha256 !== '' ? substr($tokenSha256, 0, 12) : '', 'token_sha256' => $tokenSha256, 'hfr_id' => $this->hfrId, 'base_url' => $this->baseUrl, ]; if ($curlErr !== '') { log_message('error', '[EAtriaBridge] cURL error on ' . $url . ': ' . $curlErr); $this->dbLog($method, $path, $url, $body, 0, '', 'error', 'cURL error: ' . $curlErr . ' | auth=' . json_encode($authDebug)); return ['ok' => 0, 'error_text' => 'cURL error: ' . $curlErr, 'http_code' => 0, 'auth_debug' => $authDebug]; } $decoded = json_decode((string) $raw, true); if (!is_array($decoded)) { $this->dbLog($method, $path, $url, $body, $httpCode, (string) $raw, 'error', 'Non-JSON response | auth=' . json_encode($authDebug)); return ['ok' => 0, 'error_text' => 'Non-JSON response', 'http_code' => $httpCode, 'raw' => (string) $raw, 'auth_debug' => $authDebug]; } $ok = ($httpCode >= 200 && $httpCode < 300) ? (int) ($decoded['ok'] ?? 1) : 0; $logErr = $ok === 0 ? (string) ($decoded['message'] ?? $decoded['error_text'] ?? '') : ''; if ($ok === 0) { $logErr .= ($logErr !== '' ? ' | ' : '') . 'auth=' . json_encode($authDebug); } $this->dbLog($method, $path, $url, $body, $httpCode, (string) $raw, $ok === 1 ? 'success' : 'error', $logErr); if (($httpCode === 401 || $httpCode === 403) && ! isset($decoded['auth_hint'])) { $decoded['auth_hint'] = 'Verify hospital_setting.EATRIA_BRIDGE_TOKEN mapped to HFR IN0510000870. Current token source=' . $this->tokenSource . ', HFR=' . ($this->hfrId !== '' ? $this->hfrId : '(empty)'); } $decoded['auth_debug'] = $authDebug; return array_merge($decoded, ['ok' => $ok, 'http_code' => $httpCode]); } // ------------------------------------------------------------------------- // DB Audit Log // ------------------------------------------------------------------------- /** * Write one row to abdm_api_logs (fail-open — never throws). * * @param array $requestBody */ private function dbLog( string $method, string $path, string $fullUrl, array $requestBody, int $httpCode, string $rawResponse, string $status, string $errorMessage ): void { try { $db = \Config\Database::connect(); if (! $db->tableExists('abdm_api_logs')) { return; } // Derive a human-readable event type from the path, e.g. /v3/records/push → records.push $eventType = ltrim($path, '/'); $eventType = preg_replace('#^v\d+/#', '', $eventType) ?? $eventType; // strip version prefix $eventType = str_replace('/', '.', $eventType); $decodedResp = json_decode($rawResponse, true); $responseJson = is_array($decodedResp) ? (string) json_encode($this->redactSensitiveForLog($decodedResp), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (trim($rawResponse) !== '' ? $rawResponse : null); $safeRequestBody = $this->redactSensitiveForLog($requestBody); $db->table('abdm_api_logs')->insert([ 'channel' => 'eatria_bridge', 'event_type' => $eventType !== '' ? $eventType : $path, 'endpoint' => $fullUrl, 'http_method' => strtoupper($method), 'entity_type' => null, 'entity_id' => null, 'request_json' => (string) json_encode($safeRequestBody, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'response_code' => $httpCode > 0 ? $httpCode : null, 'response_json' => $responseJson, 'status' => $status, 'error_message' => $errorMessage !== '' ? mb_substr($errorMessage, 0, 1000) : null, 'created_at' => date('Y-m-d H:i:s'), ]); } catch (\Throwable $e) { log_message('warning', '[EAtriaBridge] dbLog failed: ' . $e->getMessage()); } } // ------------------------------------------------------------------------- // ABHA // ------------------------------------------------------------------------- public function validateAbha(string $abhaId, array $fullPayload = []): array { $body = $fullPayload !== [] ? $fullPayload : ['abha_id' => $abhaId]; if ($this->hfrId !== '' && empty($body['hfr_id'])) { $body['hfr_id'] = $this->hfrId; } return $this->post('/v3/abha/validate', $body); } // ------------------------------------------------------------------------- // M1 ABHA OTP Flows // ------------------------------------------------------------------------- public function abhaAadhaarGenerateOtp(array $payload): array { // Gateway simple format: { aadhaar: "<12-digit>" } $body = [ 'aadhaar' => (string) ($payload['aadhaar'] ?? $payload['loginId'] ?? ''), ]; if ($this->hfrId !== '' && empty($body['hfr_id'])) { $body['hfr_id'] = $this->hfrId; } return $this->post('/v3/abha/aadhaar/generate-otp', $body); } public function abhaAadhaarVerifyOtp(array $payload): array { // Gateway simple format: { txnId: "...", otp: "123456", mobile: "9876543210" (optional) } $body = [ 'txnId' => (string) ($payload['txnId'] ?? $payload['txn_id'] ?? ''), 'otp' => (string) ($payload['otp'] ?? ''), ]; // Only include mobile when a valid 10-digit number is supplied; // sending an empty string causes ABDM sandbox to reject the verify request. $mobile = (string) ($payload['mobile'] ?? ''); if (preg_match('/^\d{10}$/', $mobile)) { $body['mobile'] = $mobile; } if ($this->hfrId !== '' && empty($body['hfr_id'])) { $body['hfr_id'] = $this->hfrId; } return $this->post('/v3/abha/aadhaar/verify-otp', $body); } public function abhaMobileGenerateOtp(array $payload): array { // Gateway API format: { "mobile": "<10-digit>" } // Gateway handles RSA encryption and M3 format conversion internally. $body = [ 'mobile' => (string) ($payload['mobile'] ?? $payload['loginId'] ?? ''), ]; if ($this->hfrId !== '' && empty($body['hfr_id'])) { $body['hfr_id'] = $this->hfrId; } return $this->post('/v3/abha/mobile/generate-otp', $body); } public function abhaMobileVerifyOtp(array $payload): array { // Gateway API format: { "txnId": "...", "otp": "123456" } $body = [ 'txnId' => (string) ($payload['txnId'] ?? $payload['txn_id'] ?? ''), 'otp' => (string) ($payload['otp'] ?? ''), ]; if ($this->hfrId !== '' && empty($body['hfr_id'])) { $body['hfr_id'] = $this->hfrId; } return $this->post('/v3/abha/mobile/verify-otp', $body); } // ------------------------------------------------------------------------- // Consent // ------------------------------------------------------------------------- public function requestConsent( int $patientId, string $abhaId, string $purposeCode, string $expiresAt, string $consentHandle, array $rawPayload = [] ): array { $body = [ 'patient_abha' => $abhaId, 'purpose' => $purposeCode !== '' ? $purposeCode : 'TREATMENT', 'hi_types' => $rawPayload['hi_types'] ?? ['OPConsultation'], ]; if ($expiresAt !== '') { $body['expires_at'] = $expiresAt; } $result = $this->post('/v3/consent/request', $body); // Merge consent_id from gateway response so callers can persist it if (isset($result['consent_id'])) { $result['gateway_consent_id'] = $result['consent_id']; } return $result; } // ------------------------------------------------------------------------- // Health Record Sharing // ------------------------------------------------------------------------- public function sharePrescriptionBundle(array $payload, string $entityId = ''): array { $hiTypeRaw = (string) ($payload['hi_type'] ?? 'OPConsultRecord'); $hiType = match ($hiTypeRaw) { 'OPConsultation', 'OPConsultRecord' => 'OPConsultRecord', 'Prescription', 'PrescriptionRecord' => 'PrescriptionRecord', 'DiagnosticReport', 'DiagnosticReportRecord' => 'DiagnosticReportRecord', 'DischargeSummary', 'DischargeSummaryRecord' => 'DischargeSummaryRecord', 'ImmunizationRecord' => 'ImmunizationRecord', 'WellnessRecord' => 'WellnessRecord', 'HealthDocumentRecord' => 'HealthDocumentRecord', 'InvoiceRecord' => 'InvoiceRecord', default => $hiTypeRaw, }; return $this->post('/v3/bundle/push', [ 'consent_id' => (string) ($payload['consent_id'] ?? $payload['consent_handle'] ?? ''), 'hi_type' => $hiType, 'fhir_bundle' => $payload['bundle'] ?? $payload['fhir_bundle'] ?? [], ]); } public function shareIpdDischargeBundle(array $payload, string $entityId = ''): array { return $this->post('/v3/bundle/push', [ 'consent_id' => (string) ($payload['consent_id'] ?? $payload['consent_handle'] ?? ''), 'hi_type' => 'DischargeSummaryRecord', 'fhir_bundle' => $payload['bundle'] ?? $payload['fhir_bundle'] ?? [], ]); } public function shareDiagnosisReportBundle(array $payload, string $entityId = ''): array { return $this->post('/v3/bundle/push', [ 'consent_id' => (string) ($payload['consent_id'] ?? $payload['consent_handle'] ?? ''), 'hi_type' => 'DiagnosticReportRecord', 'fhir_bundle' => $payload['bundle'] ?? $payload['fhir_bundle'] ?? [], ]); } // ------------------------------------------------------------------------- // Health Records — store-and-link flow POST /api/v3/records/push // ------------------------------------------------------------------------- public function pushRecord(array $data): array { $recordTypeRaw = (string) ($data['record_type'] ?? ''); $hiTypeRaw = (string) ($data['hi_type'] ?? ''); $normalizeHiType = static function (string $value): string { return match ($value) { 'OPConsultation', 'OPConsultRecord' => 'OPConsultRecord', 'Prescription', 'PrescriptionRecord' => 'PrescriptionRecord', 'DiagnosticReport', 'DiagnosticReportRecord', 'Lab_Report', 'lab_report' => 'DiagnosticReportRecord', 'DischargeSummary', 'DischargeSummaryRecord' => 'DischargeSummaryRecord', 'ImmunizationRecord' => 'ImmunizationRecord', 'WellnessRecord' => 'WellnessRecord', 'HealthDocumentRecord' => 'HealthDocumentRecord', 'InvoiceRecord' => 'InvoiceRecord', default => '', }; }; $normalizeRecordType = static function (string $value) use ($normalizeHiType): string { return $normalizeHiType($value); }; $recordType = $normalizeRecordType($recordTypeRaw); if ($recordType === '') { $recordType = $normalizeRecordType($hiTypeRaw); } if ($recordType === '') { $recordType = 'HealthDocumentRecord'; } $hiType = $normalizeHiType($hiTypeRaw); if ($hiType === '') { $hiType = $recordType; } $patientName = trim((string) ($data['patient_name'] ?? '')); $abhaAddress = trim((string) ($data['abha_address'] ?? '')); $abhaId = trim((string) ($data['abha_id'] ?? '')); $careContextReference = trim((string) ($data['care_context_reference'] ?? $data['careContextId'] ?? '')); $careContextDisplay = trim((string) ($data['care_context_display'] ?? $data['notes'] ?? '')); $queueId = trim((string) ($data['queue_id'] ?? '')); if ($patientName === '') { return ['ok' => 0, 'http_code' => 0, 'error_text' => 'patient_name is required']; } if ($abhaAddress === '' && $abhaId === '') { return ['ok' => 0, 'http_code' => 0, 'error_text' => 'Either abha_address or abha_id is required before pushing health record']; } if ($careContextReference === '') { return ['ok' => 0, 'http_code' => 0, 'error_text' => 'care_context_reference is required']; } if ($careContextDisplay === '') { $careContextDisplay = $recordType; } if ($queueId === '') { $queueId = $careContextReference; } // Gateway requires BOTH record_type (lowercase alias) and hi_type (official ABDM name). $body = [ 'patient_id' => (string) ($data['patient_id'] ?? ''), 'patient_name' => $patientName, 'record_type' => $recordType, 'hi_type' => $hiType, 'visit_date' => (string) ($data['visit_date'] ?? date('Y-m-d')), 'record_data' => $data['record_data'] ?? $data['bundle'] ?? (object) [], 'fhir_bundle' => $data['record_data'] ?? $data['bundle'] ?? (object) [], 'care_context_reference' => $careContextReference, 'care_context_display' => $careContextDisplay, 'queue_id' => $queueId, ]; if ($this->bridgeHospitalId !== '') { $body['hospital_id'] = $this->bridgeHospitalId; $body['bridge_hospital_id'] = $this->bridgeHospitalId; } if ($abhaAddress !== '') { $body['abha_address'] = $abhaAddress; } if ($abhaId !== '') { $body['abha_id'] = $abhaId; } // Also send ABDM-style careContexts wrapper so consent linkage remains explicit. $body['careContexts'] = [[ 'careContextReference' => $careContextReference, 'description' => $careContextDisplay, ]]; // hfr_id is required in every push request alongside the Bearer token. if ($this->hfrId !== '') { $body['hfr_id'] = $this->hfrId; } foreach (['abha_id', 'doctor_name', 'department', 'notes', 'gender', 'year_of_birth'] as $optional) { if (empty($data[$optional])) { continue; } if ($optional === 'gender') { $gender = $this->normalizeBridgeGender($data[$optional]); if ($gender !== '') { $body['gender'] = $gender; } continue; } $body[$optional] = (string) $data[$optional]; } return $this->post('/v3/records/push', $body); } // ------------------------------------------------------------------------- // Scan & Share // ------------------------------------------------------------------------- public function scanShareLookup(string $qrPayload, string $abhaIdHint = '', array $fullPayload = []): array { // Per API docs: scan & share uses POST /api/v1/bridge with event_type abdm.scan_share.lookup return $this->post('/v1/bridge', [ 'event_type' => 'abdm.scan_share.lookup', 'payload' => [ 'term' => $qrPayload, 'return_limit' => (int) ($fullPayload['return_limit'] ?? 10), ], ]); } // ------------------------------------------------------------------------- // NHCX Claims // ------------------------------------------------------------------------- public function nhcxClaimCreate( array $bundle, int $documentId, int $patientId, int $encounterId ): array { // Gateway does not yet expose an NHCX endpoint — queue via bridge dispatch throw new \RuntimeException( 'EAtriaBridgeConnector::nhcxClaimCreate() — NHCX endpoint not yet available on gateway. ' . 'Use DreamsoftConnector or direct NHCX integration.' ); } public function nhcxClaimStatusRequest( int $documentId, string $externalRef, string $currentStatus, array $fullPayload = [] ): array { throw new \RuntimeException( 'EAtriaBridgeConnector::nhcxClaimStatusRequest() — NHCX endpoint not yet available on gateway.' ); } // ------------------------------------------------------------------------- // OPD Queue (Scan & Share + Manual walk-in tokens) // ------------------------------------------------------------------------- public function getRecord(int $bridgeId): array { $query = []; if ($this->hfrId !== '') { $query['hfr_id'] = $this->hfrId; } return $this->get('/v3/records/' . $bridgeId, $query); } public function triggerShare(int $bridgeId): array { $body = []; if ($this->hfrId !== '') { $body['hfr_id'] = $this->hfrId; } return $this->post('/v3/records/' . $bridgeId . '/share', $body); } public function linkAndShare(int $bridgeId): array { $body = []; if ($this->hfrId !== '') { $body['hfr_id'] = $this->hfrId; } return $this->post('/v3/records/' . $bridgeId . '/link-and-share', $body); } public function workflowStatus(int $bridgeId): array { $query = []; if ($this->hfrId !== '') { $query['hfr_id'] = $this->hfrId; } return $this->get('/v3/records/' . $bridgeId . '/workflow-status', $query); } public function opdQueueFetch(string $date = '', string $status = '', int $page = 1, int $limit = 100): array { $params = ['limit' => $limit, 'page' => $page]; if ($date !== '') { $params['date'] = $date; } if ($status !== '') { $params['status'] = $status; } return $this->get('/v3/opd/queue', $params); } public function opdTokenCreate(array $payload): array { if (array_key_exists('gender', $payload)) { $gender = $this->normalizeBridgeGender($payload['gender']); if ($gender !== '') { $payload['gender'] = $gender; } else { unset($payload['gender']); } } return $this->post('/v3/opd/token', $payload); } public function opdTokenUpdateStatus(int $tokenId, string $status): array { return $this->patch('/v3/opd/token/' . $tokenId, ['status' => $status]); } public function opdRunningTokenStatus(): array { return $this->get('/v3/opd/running-token-status'); } // ------------------------------------------------------------------------- // Bridge Records — list // ------------------------------------------------------------------------- public function getRecords(array $filters = []): array { return $this->get('/v3/records', $filters); } // ------------------------------------------------------------------------- // System / Hospital Info // ------------------------------------------------------------------------- public function gatewayStatus(): array { return $this->get('/v3/gateway/status'); } // ------------------------------------------------------------------------- // HIP-Initiated Linking // ------------------------------------------------------------------------- public function hipLinkToken(array $payload): array { if (array_key_exists('gender', $payload)) { $gender = $this->normalizeBridgeGender($payload['gender']); if ($gender !== '') { $payload['gender'] = $gender; } else { unset($payload['gender']); } } if ($this->hfrId !== '' && empty($payload['hfr_id'])) { $payload['hfr_id'] = $this->hfrId; } return $this->post('/v3/hip/link-token', $payload); } public function hipLinkCareContext(array $payload): array { if ($this->hfrId !== '' && empty($payload['hfr_id'])) { $payload['hfr_id'] = $this->hfrId; } return $this->post('/v3/hip/link/carecontext', $payload); } public function hipGetPatientLinks(array $filters = []): array { return $this->get('/v3/hip/link/patient/links', $filters); } public function hipLinkNotify(array $payload): array { return $this->post('/v3/hip/link/notify', $payload); } public function hipSmsNotify(array $payload): array { return $this->post('/v3/hip/link/sms-notify', $payload); } // ------------------------------------------------------------------------- // Drug Terminology (CDCI) // ------------------------------------------------------------------------- public function drugsAutocomplete(string $q, string $type = 'generic', int $limit = 10): array { $query = [ 'q' => $q, 'type' => $type, 'limit' => max(1, min(50, $limit)), ]; if ($this->hfrId !== '') { $query['hfr_id'] = $this->hfrId; } return $this->get('/v3/drugs/autocomplete', $query); } public function drugsDetail(string $type, string $identifier): array { $query = [ 'type' => $type, 'identifier' => $identifier, ]; if ($this->hfrId !== '') { $query['hfr_id'] = $this->hfrId; } return $this->get('/v3/drugs/detail', $query); } public function drugsVersion(): array { $query = []; if ($this->hfrId !== '') { $query['hfr_id'] = $this->hfrId; } return $this->get('/v3/drugs/version', $query); } // ------------------------------------------------------------------------- // Pathology Terminology (LOINC) // ------------------------------------------------------------------------- /** * Fetch pathology panel masters from the Bridge API. * * @param string $subCategory 'PATHOLOGY' | 'BIOPSY' | '' for all * @param int $limit items per page (max 500) * @param int $offset pagination offset * @param string $updatedSince ISO-8601 datetime for incremental sync * @return array */ public function pathologyMasters( string $subCategory = '', int $limit = 200, int $offset = 0, string $updatedSince = '' ): array { $query = ['limit' => max(1, min(500, $limit)), 'offset' => $offset]; if ($subCategory !== '') { $query['sub_category'] = strtoupper($subCategory); } if ($updatedSince !== '') { $query['updated_since'] = $updatedSince; } if ($this->hfrId !== '') { $query['hfr_id'] = $this->hfrId; } return $this->get('/v3/pathology/masters', $query); } /** * Fetch LOINC component details for a specific pathology panel. * * @param string $parentTest Exact panel name (e.g. 'Complete Blood Count Panel') * @param int $limit items per page * @param int $offset pagination offset * @param string $updatedSince ISO-8601 datetime for incremental sync * @return array */ public function pathologyComponents( string $parentTest = '', int $limit = 200, int $offset = 0, string $updatedSince = '' ): array { $query = ['limit' => max(1, min(500, $limit)), 'offset' => $offset]; if ($parentTest !== '') { $query['parent_test'] = $parentTest; } if ($updatedSince !== '') { $query['updated_since'] = $updatedSince; } if ($this->hfrId !== '') { $query['hfr_id'] = $this->hfrId; } return $this->get('/v3/pathology/components', $query); } }