2097 lines
86 KiB
PHP
2097 lines
86 KiB
PHP
<?php
|
|
|
|
namespace App\Libraries\Abdm;
|
|
|
|
/**
|
|
* EAtriaBridgeConnector
|
|
*
|
|
* Calls the e-Atria ABDM Bridge Gateway (abdm-bridge.e-atria.in) directly.
|
|
* All operations are synchronous HTTP calls to /api/v3/* endpoints.
|
|
*
|
|
* Gateway API reference: https://abdm-bridge.e-atria.in/api/v3/health
|
|
*
|
|
* Configure via .env:
|
|
* abdm.connector = eatria_bridge
|
|
* EATRIA_BRIDGE_URL = https://abdm-bridge.e-atria.in/api
|
|
* EATRIA_BRIDGE_TOKEN = <api-key-from-gateway-admin>
|
|
* EATRIA_BRIDGE_TIMEOUT = 30
|
|
*/
|
|
class EAtriaBridgeConnector implements AbdmConnectorInterface
|
|
{
|
|
private string $baseUrl = '';
|
|
private string $token = '';
|
|
private string $hfrId = '';
|
|
private string $bridgeHospitalId = '';
|
|
private int $timeoutSec = 30;
|
|
private bool $sslVerify = true;
|
|
/** @var array<int, string> */
|
|
private array $tokenCandidates = [];
|
|
/** @var array<string, string> */
|
|
private array $tokenSourceByValue = [];
|
|
private string $tokenSource = 'config';
|
|
/** @var array<int, float> */
|
|
protected array $linkTokenRetryDelays = [1.5, 2.0, 2.5];
|
|
|
|
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 = '';
|
|
$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',
|
|
'ABDM_BRIDGE_SSL_VERIFY',
|
|
]);
|
|
|
|
// 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 === '') {
|
|
log_message('warning', '[EAtriaBridge] ABDM_HFR_ID is empty in hospital_setting; bridge requests may fail with hfr_id errors.');
|
|
} else {
|
|
$this->hfrId = $dbHfrId;
|
|
}
|
|
|
|
$dbBridgeHospitalId = trim((string) (
|
|
$dbSettings['ABDM_BRIDGE_HOSPITAL_ID']
|
|
?? $dbSettings['EATRIA_BRIDGE_HOSPITAL_ID']
|
|
?? ''
|
|
));
|
|
if ($dbBridgeHospitalId !== '') {
|
|
$this->bridgeHospitalId = $dbBridgeHospitalId;
|
|
}
|
|
|
|
// SSL verification stays ON by default (secure). Admin → ABDM Gateway
|
|
// page can opt out per-deployment for servers with a stale local CA
|
|
// bundle (cURL error 60); an env override on that specific machine
|
|
// takes precedence over the DB setting.
|
|
$dbSslVerifyRaw = strtolower(trim((string) ($dbSettings['ABDM_BRIDGE_SSL_VERIFY'] ?? '')));
|
|
if ($dbSslVerifyRaw !== '') {
|
|
$this->sslVerify = ! in_array($dbSslVerifyRaw, ['0', 'false', 'no', 'off'], true);
|
|
}
|
|
} 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 <token>" 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<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
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',
|
|
'loginid', 'login_id', 'mobile', 'mobilenumber', 'mobile_number', 'phone', 'phone_number',
|
|
], 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<string, mixed> $body
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function post(string $path, array $body): array
|
|
{
|
|
return $this->httpCall('POST', $path, $body);
|
|
}
|
|
|
|
protected function patch(string $path, array $body): array
|
|
{
|
|
return $this->httpCall('PATCH', $path, $body);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
* @param array<string, string> $extraHeaders
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function get(string $path, array $query = [], array $extraHeaders = []): 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, $extraHeaders);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $body
|
|
* @param array<string, mixed> $query
|
|
* @param array<string, string> $extraHeaders
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function httpCall(string $method, string $path, array $body = [], array $query = [], array $extraHeaders = []): 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, $extraHeaders): array {
|
|
$headers = [
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
];
|
|
if ($tokenValue !== '') {
|
|
$headers[] = 'Authorization: Bearer ' . $tokenValue;
|
|
}
|
|
if ($this->bridgeHospitalId !== '') {
|
|
$headers[] = 'X-Hospital-Id: ' . $this->bridgeHospitalId;
|
|
}
|
|
foreach ($extraHeaders as $headerName => $headerValue) {
|
|
if (trim((string) $headerValue) !== '') {
|
|
$headers[] = $headerName . ': ' . $headerValue;
|
|
}
|
|
}
|
|
|
|
$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));
|
|
|
|
// SSL verification stays ON by default. Some local WAMP/XAMPP
|
|
// installs ship a stale bundled cacert.pem, causing cURL error 60
|
|
// on outbound HTTPS calls. Fix that by updating curl.cainfo in
|
|
// php.ini on the affected machine, or opt out for this specific
|
|
// deployment via Admin → ABDM Gateway (or ABDM_BRIDGE_SSL_VERIFY=false
|
|
// in that machine's .env, which takes precedence).
|
|
$sslVerifyEnvRaw = strtolower(trim((string) (getenv('ABDM_BRIDGE_SSL_VERIFY') ?: '')));
|
|
$sslVerify = $sslVerifyEnvRaw !== ''
|
|
? ! in_array($sslVerifyEnvRaw, ['0', 'false', 'no', 'off'], true)
|
|
: $this->sslVerify;
|
|
|
|
$ch = curl_init();
|
|
$curlOptions = [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => $this->timeoutSec,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_SSL_VERIFYPEER => $sslVerify,
|
|
CURLOPT_SSL_VERIFYHOST => $sslVerify ? 2 : 0,
|
|
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];
|
|
}
|
|
|
|
$authResult = strtolower(trim((string) ($decoded['authResult'] ?? $decoded['auth_result'] ?? $decoded['data']['authResult'] ?? $decoded['data']['auth_result'] ?? '')));
|
|
$statusVal = strtolower(trim((string) ($decoded['status'] ?? $decoded['data']['status'] ?? '')));
|
|
$hasFailedAuth = ($authResult === 'failed' || in_array($statusVal, ['failed', 'failure', 'error', 'rejected'], true));
|
|
if (isset($decoded['success']) && ($decoded['success'] === false || $decoded['success'] === 0 || $decoded['success'] === 'false')) {
|
|
$hasFailedAuth = true;
|
|
}
|
|
if (isset($decoded['data']['success']) && ($decoded['data']['success'] === false || $decoded['data']['success'] === 0 || $decoded['data']['success'] === 'false')) {
|
|
$hasFailedAuth = true;
|
|
}
|
|
|
|
if ($hasFailedAuth) {
|
|
$ok = 0;
|
|
} elseif ($httpCode >= 200 && $httpCode < 300) {
|
|
$ok = (int) ($decoded['ok'] ?? 1);
|
|
} else {
|
|
$ok = 0;
|
|
}
|
|
|
|
if ($ok === 0) {
|
|
$extractedErr = '';
|
|
|
|
// Check for specific rejection data inside data (e.g. loginHint, details)
|
|
if (isset($decoded['data']) && is_array($decoded['data'])) {
|
|
if (!empty($decoded['data']['loginHint'])) {
|
|
$hint = trim((string) $decoded['data']['loginHint']);
|
|
$extractedErr = 'ABDM gateway rejected login hint: ' . $hint . ' (ABHA Address or number not found)';
|
|
} elseif (!empty($decoded['data']['details'][0]['message'])) {
|
|
$extractedErr = trim((string) $decoded['data']['details'][0]['message']);
|
|
} elseif (!empty($decoded['data']['message'])) {
|
|
$extractedErr = trim((string) $decoded['data']['message']);
|
|
}
|
|
}
|
|
|
|
if ($extractedErr === '' && isset($decoded['error']) && is_array($decoded['error'])) {
|
|
if (!empty($decoded['error']['details'][0]['message'])) {
|
|
$extractedErr = trim((string) $decoded['error']['details'][0]['message']);
|
|
} elseif (!empty($decoded['error']['message']) && $decoded['error']['message'] !== 'ABDM gateway returned an error.') {
|
|
$extractedErr = trim((string) $decoded['error']['message']);
|
|
}
|
|
}
|
|
|
|
if ($extractedErr === '') {
|
|
$rawMsg = trim((string) ($decoded['message'] ?? $decoded['data']['message'] ?? $decoded['error']['message'] ?? (is_string($decoded['error'] ?? null) ? $decoded['error'] : '')));
|
|
if ($rawMsg !== '') {
|
|
$extractedErr = $rawMsg;
|
|
}
|
|
}
|
|
|
|
$targetVal = (string) ($body['value'] ?? $body['abha_address'] ?? $body['abha_id'] ?? '');
|
|
$isNumberLookup = (($body['type'] ?? '') === 'abha-number')
|
|
|| (isset($body['abha_id']) && !isset($body['abha_address']))
|
|
|| (!str_contains($targetVal, '@') && $targetVal !== '' && ctype_digit(str_replace(['-', ' '], '', $targetVal)));
|
|
$entityLabel = $isNumberLookup ? 'ABHA Number' : 'ABHA Address';
|
|
|
|
// Normalise technical errors when an ABHA search/validate is performed
|
|
$isSearchPath = str_contains($path, '/abha/login/search') || str_contains($path, '/abha/validate');
|
|
if ($isSearchPath) {
|
|
if (stripos($extractedErr, 'preferredAbhaAddress') !== false ||
|
|
(stripos($extractedErr, 'Unknown column') !== false && stripos($extractedErr, 'where clause') !== false) ||
|
|
stripos($extractedErr, 'Invalid Login Hint') !== false ||
|
|
stripos($extractedErr, 'rejected login hint') !== false ||
|
|
stripos($extractedErr, 'Please make a valid request') !== false ||
|
|
stripos($extractedErr, 'ABDM gateway returned an error') !== false ||
|
|
stripos($extractedErr, 'ABDM_UPSTREAM_ERROR') !== false ||
|
|
stripos($extractedErr, 'not found') !== false ||
|
|
stripos($extractedErr, 'does not exist') !== false) {
|
|
$decoded['raw_bridge_error'] = $extractedErr;
|
|
$extractedErr = $entityLabel . ' does not exist or was not found in ABDM registry' . ($targetVal !== '' ? ' (' . $targetVal . ')' : '');
|
|
}
|
|
} else {
|
|
if (stripos($extractedErr, 'preferredAbhaAddress') !== false ||
|
|
(stripos($extractedErr, 'Unknown column') !== false && stripos($extractedErr, 'where clause') !== false)) {
|
|
$decoded['raw_bridge_error'] = $extractedErr;
|
|
$extractedErr = $entityLabel . ' does not exist or was not found in ABDM registry' . ($targetVal !== '' ? ' (' . $targetVal . ')' : '');
|
|
} elseif (stripos($extractedErr, 'Invalid Login Hint') !== false || stripos($extractedErr, 'rejected login hint') !== false) {
|
|
$extractedErr = $entityLabel . ' does not exist or was not found in ABDM registry' . ($targetVal !== '' ? ' (' . $targetVal . ')' : '');
|
|
}
|
|
}
|
|
|
|
if (preg_match('/(?:after\s*30\s*seconds|request\s*(?:for\s*)?new\s*otp\s*after\s*30\s*seconds)/i', $extractedErr)) {
|
|
$extractedErr = 'You can request for new OTP after 60 seconds';
|
|
} elseif (preg_match('/30\s*seconds/i', $extractedErr)) {
|
|
$extractedErr = preg_replace('/30\s*seconds/i', '60 seconds', $extractedErr);
|
|
}
|
|
if (isset($decoded['message']) && is_string($decoded['message']) && preg_match('/30\s*seconds/i', $decoded['message'])) {
|
|
$decoded['message'] = preg_replace('/30\s*seconds/i', '60 seconds', $decoded['message']);
|
|
}
|
|
if (isset($decoded['data']['message']) && is_string($decoded['data']['message']) && preg_match('/30\s*seconds/i', $decoded['data']['message'])) {
|
|
$decoded['data']['message'] = preg_replace('/30\s*seconds/i', '60 seconds', $decoded['data']['message']);
|
|
}
|
|
|
|
if ($extractedErr !== '') {
|
|
$decoded['error_text'] = $extractedErr;
|
|
}
|
|
}
|
|
$logErr = $ok === 0 ? (string) ($decoded['error_text'] ?? $decoded['message'] ?? '') : '';
|
|
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 is mapped to the same HFR as hospital_setting.ABDM_HFR_ID. 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<string, mixed> $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
|
|
{
|
|
$inputValue = '';
|
|
$inputType = 'abha-number';
|
|
|
|
if ($fullPayload !== []) {
|
|
$abhaAddress = trim((string) ($fullPayload['abha_address'] ?? ''));
|
|
$abhaNumber = trim((string) ($fullPayload['abha_id'] ?? $abhaId));
|
|
|
|
if ($abhaAddress !== '') {
|
|
$inputType = 'abha-address';
|
|
$inputValue = $abhaAddress;
|
|
} else {
|
|
$digits = preg_replace('/\D/', '', $abhaNumber);
|
|
$inputValue = $digits !== '' ? $digits : $abhaNumber;
|
|
}
|
|
} else {
|
|
$abhaRaw = trim((string) $abhaId);
|
|
if (str_contains($abhaRaw, '@')) {
|
|
$inputType = 'abha-address';
|
|
$inputValue = $abhaRaw;
|
|
} else {
|
|
$digits = preg_replace('/\D/', '', $abhaRaw);
|
|
$inputValue = $digits !== '' ? $digits : $abhaRaw;
|
|
}
|
|
}
|
|
|
|
$body = [
|
|
'type' => $inputType,
|
|
'value' => $inputValue,
|
|
];
|
|
if ($inputType === 'abha-address') {
|
|
$body['abha_address'] = $inputValue;
|
|
} else {
|
|
$body['abha_id'] = $inputValue;
|
|
}
|
|
if ($this->hfrId !== '') {
|
|
$body['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
$result = $this->post('/v3/abha/login/search', $body);
|
|
|
|
// Backward-compat: if a bridge does not yet expose login/search, retry legacy validate.
|
|
if (empty($result['ok']) || (int) ($result['ok'] ?? 0) !== 1) {
|
|
$httpCode = (int) ($result['http_code'] ?? 0);
|
|
if (in_array($httpCode, [404, 405], true)) {
|
|
$legacyBody = $inputType === 'abha-address'
|
|
? ['abha_address' => $inputValue]
|
|
: ['abha_id' => $inputValue];
|
|
if ($this->hfrId !== '') {
|
|
$legacyBody['hfr_id'] = $this->hfrId;
|
|
}
|
|
$result = $this->post('/v3/abha/validate', $legacyBody);
|
|
}
|
|
}
|
|
|
|
if (empty($result['ok']) || (int) ($result['ok'] ?? 0) !== 1) {
|
|
return $result;
|
|
}
|
|
|
|
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
|
|
$nestedResult = is_array($result['result'] ?? null) ? $result['result'] : [];
|
|
$accounts = is_array($data['accounts'] ?? null) ? $data['accounts'] : [];
|
|
$firstAccount = isset($accounts[0]) && is_array($accounts[0])
|
|
? $accounts[0]
|
|
: (is_array($data['account'] ?? null) ? $data['account'] : []);
|
|
|
|
$txnId = trim((string) (
|
|
$result['txn_id'] ?? $result['txnId']
|
|
?? $data['txn_id'] ?? $data['txnId']
|
|
?? $data['transaction_id'] ?? $data['transactionId']
|
|
?? $nestedResult['txn_id'] ?? $nestedResult['txnId']
|
|
?? $nestedResult['transaction_id'] ?? $nestedResult['transactionId']
|
|
?? $firstAccount['txn_id'] ?? $firstAccount['txnId'] ?? ''
|
|
));
|
|
|
|
$status = '';
|
|
foreach ([
|
|
$result['status'] ?? null,
|
|
$result['account_status'] ?? null,
|
|
$data['status'] ?? null,
|
|
$data['account_status'] ?? null,
|
|
$data['accountStatus'] ?? null,
|
|
$data['abhaStatus'] ?? null,
|
|
$firstAccount['status'] ?? null,
|
|
$firstAccount['accountStatus'] ?? null,
|
|
$firstAccount['abhaStatus'] ?? null,
|
|
] as $candidate) {
|
|
if (is_string($candidate) && trim($candidate) !== '') {
|
|
$status = strtoupper(trim($candidate));
|
|
break;
|
|
}
|
|
}
|
|
|
|
$authMethods = [];
|
|
foreach ([
|
|
$result['authMethods'] ?? null,
|
|
$result['auth_methods'] ?? null,
|
|
$data['authMethods'] ?? null,
|
|
$data['auth_methods'] ?? null,
|
|
$data['availableAuthMethods'] ?? null,
|
|
$nestedResult['authMethods'] ?? null,
|
|
$nestedResult['auth_methods'] ?? null,
|
|
$nestedResult['availableAuthMethods'] ?? null,
|
|
$firstAccount['authMethods'] ?? null,
|
|
$firstAccount['auth_methods'] ?? null,
|
|
$firstAccount['availableAuthMethods'] ?? null,
|
|
] as $candidate) {
|
|
if (is_array($candidate) && ! empty($candidate)) {
|
|
foreach ($candidate as $method) {
|
|
$m = strtoupper(trim((string) $method));
|
|
if ($m !== '' && ! in_array($m, $authMethods, true)) {
|
|
$authMethods[] = $m;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! empty($authMethods)) {
|
|
$result['auth_methods'] = $authMethods;
|
|
}
|
|
|
|
if ($txnId !== '') {
|
|
$result['txn_id'] = $txnId;
|
|
}
|
|
|
|
$maskedMobile = trim((string) (
|
|
$result['masked_mobile'] ?? $result['maskedMobile']
|
|
?? $data['masked_mobile'] ?? $data['maskedMobile'] ?? $data['mobile']
|
|
?? $data['mobile_number'] ?? $data['phone'] ?? $data['phone_number']
|
|
?? $firstAccount['masked_mobile'] ?? $firstAccount['maskedMobile'] ?? $firstAccount['mobile']
|
|
?? $firstAccount['mobile_number'] ?? $firstAccount['phone'] ?? $firstAccount['phone_number'] ?? ''
|
|
));
|
|
if ($maskedMobile !== '' && preg_match('/\d/', $maskedMobile) === 1) {
|
|
$result['masked_mobile'] = $maskedMobile;
|
|
} else {
|
|
unset($result['masked_mobile'], $result['maskedMobile']);
|
|
}
|
|
|
|
$aadhaarMaskedMobile = trim((string) (
|
|
$result['aadhaar_masked_mobile'] ?? $result['aadhaarMaskedMobile']
|
|
?? $data['aadhaar_masked_mobile'] ?? $data['aadhaarMaskedMobile']
|
|
?? $firstAccount['aadhaar_masked_mobile'] ?? $firstAccount['aadhaarMaskedMobile'] ?? ''
|
|
));
|
|
if ($aadhaarMaskedMobile !== '' && preg_match('/\d/', $aadhaarMaskedMobile) === 1) {
|
|
$result['aadhaar_masked_mobile'] = $aadhaarMaskedMobile;
|
|
}
|
|
|
|
$resolvedAbhaNumber = '';
|
|
foreach ([
|
|
$data['healthIdNumber'] ?? null,
|
|
$data['ABHANumber'] ?? null,
|
|
$data['abhaNumber'] ?? null,
|
|
$data['abha_number'] ?? null,
|
|
$firstAccount['healthIdNumber'] ?? null,
|
|
$firstAccount['ABHANumber'] ?? null,
|
|
$firstAccount['abhaNumber'] ?? null,
|
|
$firstAccount['abha_number'] ?? null,
|
|
$result['abha_number'] ?? null,
|
|
$result['healthIdNumber'] ?? null,
|
|
] as $candidateNum) {
|
|
if (is_string($candidateNum) && trim($candidateNum) !== '') {
|
|
$resolvedAbhaNumber = trim($candidateNum);
|
|
break;
|
|
}
|
|
}
|
|
if ($resolvedAbhaNumber !== '') {
|
|
$result['abha_number'] = $resolvedAbhaNumber;
|
|
if (isset($result['account']) && is_array($result['account'])) {
|
|
$result['account']['abha_number'] = $resolvedAbhaNumber;
|
|
$result['account']['abhaNumber'] = $resolvedAbhaNumber;
|
|
}
|
|
if (isset($result['data']['account']) && is_array($result['data']['account'])) {
|
|
$result['data']['account']['abha_number'] = $resolvedAbhaNumber;
|
|
$result['data']['account']['abhaNumber'] = $resolvedAbhaNumber;
|
|
}
|
|
}
|
|
|
|
// HMS callers expect a status-like field; treat successful search as VALID when absent.
|
|
$result['status'] = $status !== '' ? $status : 'VALID';
|
|
return $result;
|
|
}
|
|
|
|
public function abhaLoginRequestOtp(array $payload): array
|
|
{
|
|
$authMethod = strtoupper((string) ($payload['auth_method'] ?? $payload['authMethod'] ?? ''));
|
|
$body = [
|
|
'txn_id' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''),
|
|
'auth_method' => $authMethod,
|
|
];
|
|
if (($payload['abha_id'] ?? '') !== '') { $body['abha_id'] = (string) $payload['abha_id']; }
|
|
if (($payload['abha_address'] ?? '') !== '') { $body['abha_address'] = (string) $payload['abha_address']; }
|
|
if ($this->hfrId !== '') {
|
|
$body['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
return $this->post('/v3/abha/login/request-otp', $body);
|
|
}
|
|
|
|
public function abhaLoginVerifyOtp(array $payload): array
|
|
{
|
|
$authMethod = strtoupper((string) ($payload['auth_method'] ?? $payload['authMethod'] ?? ''));
|
|
$body = [
|
|
'txn_id' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''),
|
|
'txnId' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''),
|
|
'auth_method' => $authMethod,
|
|
'otp' => (string) ($payload['otp'] ?? ''),
|
|
'scope' => $authMethod === 'AADHAAR_OTP'
|
|
? ['abha-login', 'aadhaar-verify']
|
|
: ['abha-login', 'mobile-verify'],
|
|
];
|
|
if (isset($payload['authData']) && is_array($payload['authData'])) {
|
|
$body['authData'] = $payload['authData'];
|
|
}
|
|
if ($this->hfrId !== '') {
|
|
$body['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
$result = $this->post('/v3/abha/login/verify-otp', $body);
|
|
$authResult = strtolower(trim((string) ($result['authResult'] ?? $result['data']['authResult'] ?? $result['auth_result'] ?? $result['data']['auth_result'] ?? '')));
|
|
$status = strtolower(trim((string) ($result['status'] ?? $result['data']['status'] ?? '')));
|
|
if ($authResult === 'failed' || in_array($status, ['failed', 'failure', 'error'], true)) {
|
|
$err = trim((string) ($result['message'] ?? $result['data']['message'] ?? $result['error_text'] ?? 'Please enter a valid OTP. Entered OTP is either expired or incorrect.'));
|
|
return array_merge($result, [
|
|
'ok' => 0,
|
|
'error_text' => $err !== '' ? $err : 'Please enter a valid OTP. Entered OTP is either expired or incorrect.',
|
|
'message' => $err !== '' ? $err : 'Please enter a valid OTP. Entered OTP is either expired or incorrect.',
|
|
]);
|
|
}
|
|
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
|
|
return $result;
|
|
}
|
|
|
|
return $this->attachOfficialAbhaCard($result);
|
|
}
|
|
|
|
public function fetchOfficialAbhaCard(array $payload): array
|
|
{
|
|
$abhaNumber = trim((string) ($payload['abha_number'] ?? $payload['abhaNumber'] ?? ''));
|
|
$abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abhaAddress'] ?? ''));
|
|
|
|
if ($abhaNumber === '' && $abhaAddress === '') {
|
|
return ['ok' => 0, 'error_text' => 'ABHA number or ABHA address is required'];
|
|
}
|
|
|
|
$patientToken = trim((string) ($payload['token'] ?? $payload['x_token'] ?? $payload['X-Token'] ?? ''));
|
|
if ($patientToken === '') {
|
|
$patientToken = $this->extractPatientXToken($payload);
|
|
}
|
|
$headers = $patientToken !== '' ? ['X-Token' => 'Bearer ' . $patientToken] : [];
|
|
|
|
$result = $this->get('/v3/abha/card', array_filter([
|
|
'abha_number' => $abhaNumber,
|
|
'abha_address' => $abhaAddress,
|
|
], static fn($value): bool => trim((string) $value) !== ''), $headers);
|
|
|
|
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
|
|
return $result;
|
|
}
|
|
|
|
$data = is_array($result['data'] ?? null) ? $result['data'] : $result;
|
|
$card = $this->extractOfficialCard($result) ?: $this->extractOfficialCard($data);
|
|
$source = $this->extractCardSource($result) ?: $this->extractCardSource($data);
|
|
|
|
if ($card === '' || $source !== 'abdm') {
|
|
return [
|
|
'ok' => 0,
|
|
'error_text' => trim((string) ($result['message'] ?? $data['message'] ?? '')) ?: 'The Bridge did not return an official ABHA card.',
|
|
'card_source' => $source,
|
|
'request_id' => (string) ($result['request_id'] ?? ''),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => 1,
|
|
'card_base64' => $card,
|
|
'card_content_type' => $this->resolveCardContentType($data),
|
|
'card_source' => $source,
|
|
'request_id' => (string) ($result['request_id'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Ensure a verify/enrol response carries the official ABHA card, fetching it
|
|
* from the Bridge card endpoint when the response itself omits it.
|
|
*
|
|
* @param array<string, mixed> $result
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function attachOfficialAbhaCard(array $result): array
|
|
{
|
|
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
|
|
$account = is_array($result['account'] ?? null)
|
|
? $result['account']
|
|
: (is_array($data['account'] ?? null) ? $data['account'] : (is_array($data['accounts'][0] ?? null) ? $data['accounts'][0] : []));
|
|
$profile = is_array($data['ABHAProfile'] ?? null)
|
|
? $data['ABHAProfile']
|
|
: (is_array($data['profile'] ?? null) ? $data['profile'] : []);
|
|
$abhaNumber = trim((string) ($account['ABHANumber'] ?? $account['abhaNumber'] ?? $account['abha_id'] ?? $data['ABHANumber'] ?? $data['abhaNumber'] ?? $data['abha_id'] ?? $profile['ABHANumber'] ?? $profile['abhaNumber'] ?? $profile['abha_id'] ?? ''));
|
|
$abhaAddress = trim((string) ($account['abhaAddress'] ?? $account['preferredAddress'] ?? $account['preferredAbhaAddress'] ?? $account['abha_address'] ?? $data['abhaAddress'] ?? $data['preferredAddress'] ?? $data['abha_address'] ?? $profile['preferredAbhaAddress'] ?? $profile['preferredAddress'] ?? $profile['abhaAddress'] ?? ''));
|
|
|
|
$card = $this->extractOfficialCard($result)
|
|
?: $this->extractOfficialCard($data)
|
|
?: $this->extractOfficialCard($account)
|
|
?: $this->extractOfficialCard($profile)
|
|
?: $this->extractOfficialCard(is_array($result['ABHAProfile'] ?? null) ? $result['ABHAProfile'] : []);
|
|
|
|
$inlineSource = $this->extractCardSource($result) ?: $this->extractCardSource($data);
|
|
if ($card !== '') {
|
|
$result['card_base64'] = $card;
|
|
$result['card_content_type'] = $this->resolveCardContentType($result) ?: $this->resolveCardContentType($data);
|
|
$result['card_source'] = $inlineSource;
|
|
}
|
|
|
|
// Verify responses often embed a Bridge-generated card under official_card,
|
|
// so only the dedicated card endpoint can supply the real ABDM card.
|
|
if (($card === '' || $inlineSource === 'generated') && ($abhaNumber !== '' || $abhaAddress !== '')) {
|
|
// Without the patient X-Token the Bridge can only return a generated
|
|
// card; with it, ABDM's genuine card is fetched.
|
|
$patientToken = $this->extractPatientXToken($result);
|
|
$cardResult = $this->get(
|
|
'/v3/abha/card',
|
|
array_filter([
|
|
'abha_number' => $abhaNumber,
|
|
'abha_address' => $abhaAddress,
|
|
], static fn($value): bool => trim((string) $value) !== ''),
|
|
$patientToken !== '' ? ['X-Token' => 'Bearer ' . $patientToken] : []
|
|
);
|
|
if (! empty($cardResult['ok']) && (int) $cardResult['ok'] === 1) {
|
|
$cardData = is_array($cardResult['data'] ?? null) ? $cardResult['data'] : $cardResult;
|
|
$fetchedCard = $this->extractOfficialCard($cardResult) ?: $this->extractOfficialCard($cardData);
|
|
$fetchedSource = $this->extractCardSource($cardResult) ?: $this->extractCardSource($cardData);
|
|
// Never trade a card we already hold for another generated one.
|
|
if ($fetchedCard !== '' && ($fetchedSource === 'abdm' || $card === '')) {
|
|
$result['card_base64'] = $fetchedCard;
|
|
$result['card_content_type'] = $this->resolveCardContentType($cardData);
|
|
$result['card_source'] = $fetchedSource;
|
|
} elseif ($fetchedCard === '') {
|
|
// Bridge reports the upstream ABDM reason here (e.g. token expired).
|
|
$result['card_message'] = trim((string) ($cardResult['message'] ?? $cardData['message'] ?? ''));
|
|
}
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $source
|
|
*/
|
|
private function extractCardSource(array $source): string
|
|
{
|
|
foreach ([$source['card_source'] ?? null, $source['source'] ?? null] as $candidate) {
|
|
$value = strtolower(trim((string) $candidate));
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
if (str_contains($value, 'abdm')) {
|
|
return 'abdm';
|
|
}
|
|
|
|
// "none" means ABDM issued no card for this session, not a local render.
|
|
return $value === 'none' ? 'none' : 'generated';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $result
|
|
*/
|
|
private function extractPatientXToken(array $result): string
|
|
{
|
|
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
|
|
$tokens = is_array($result['tokens'] ?? null) ? $result['tokens'] : (is_array($data['tokens'] ?? null) ? $data['tokens'] : []);
|
|
|
|
foreach ([
|
|
$result['X-Token'] ?? null,
|
|
$result['x_token'] ?? null,
|
|
$data['X-Token'] ?? null,
|
|
$data['x_token'] ?? null,
|
|
$result['token'] ?? null,
|
|
$data['token'] ?? null,
|
|
$tokens['token'] ?? null,
|
|
] as $candidate) {
|
|
if (is_string($candidate) && trim($candidate) !== '') {
|
|
return preg_replace('/^Bearer\s+/i', '', trim($candidate));
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $source
|
|
*/
|
|
private function extractOfficialCard(array $source): string
|
|
{
|
|
foreach (['official_card', 'card_data_uri', 'card_base64', 'card_data', 'abhaCard', 'abha_card', 'cardData', 'card'] as $key) {
|
|
$value = $source[$key] ?? '';
|
|
if (is_string($value) && trim($value) !== '') {
|
|
return trim($value);
|
|
}
|
|
if (is_array($value)) {
|
|
foreach (['base64', 'data', 'card_base64', 'card_data'] as $nestedKey) {
|
|
if (is_string($value[$nestedKey] ?? null) && trim($value[$nestedKey]) !== '') {
|
|
return trim($value[$nestedKey]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function resolveCardContentType(array $payload): string
|
|
{
|
|
$format = strtolower(trim((string) ($payload['card_content_type'] ?? $payload['content_type'] ?? $payload['card_format'] ?? '')));
|
|
if ($format === 'png') { return 'image/png'; }
|
|
if ($format === 'jpg' || $format === 'jpeg') { return 'image/jpeg'; }
|
|
if ($format === 'pdf') { return 'application/pdf'; }
|
|
return $format !== '' && str_contains($format, '/') ? $format : 'image/png';
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// M1 ABHA OTP Flows
|
|
// -------------------------------------------------------------------------
|
|
|
|
public function abhaAadhaarGenerateOtp(array $payload): array
|
|
{
|
|
// Gateway simple format: { aadhaar: "<12-digit>" }
|
|
$body = [
|
|
'aadhaar' => (string) ($payload['aadhaar'] ?? $payload['loginId'] ?? ''),
|
|
];
|
|
if (!empty($payload['purpose'])) {
|
|
$body['purpose'] = (string) $payload['purpose'];
|
|
}
|
|
if (!empty($payload['flow'])) {
|
|
$body['flow'] = (string) $payload['flow'];
|
|
}
|
|
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'] ?? ''),
|
|
];
|
|
if (!empty($payload['purpose'])) {
|
|
$body['purpose'] = (string) $payload['purpose'];
|
|
}
|
|
if (!empty($payload['flow'])) {
|
|
$body['flow'] = (string) $payload['flow'];
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
$result = $this->post('/v3/abha/aadhaar/verify-otp', $body);
|
|
$authResult = strtolower(trim((string) ($result['authResult'] ?? $result['data']['authResult'] ?? $result['auth_result'] ?? $result['data']['auth_result'] ?? '')));
|
|
$status = strtolower(trim((string) ($result['status'] ?? $result['data']['status'] ?? '')));
|
|
if ($authResult === 'failed' || in_array($status, ['failed', 'failure', 'error'], true)) {
|
|
$err = trim((string) ($result['error_text'] ?? $result['message'] ?? $result['data']['message'] ?? 'Please enter a valid OTP. Entered OTP is either expired or incorrect.'));
|
|
return array_merge($result, [
|
|
'ok' => 0,
|
|
'error_text' => $err !== '' ? $err : 'Please enter a valid OTP. Entered OTP is either expired or incorrect.',
|
|
'message' => $err !== '' ? $err : 'Please enter a valid OTP. Entered OTP is either expired or incorrect.',
|
|
]);
|
|
}
|
|
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
|
|
return $result;
|
|
}
|
|
|
|
return $this->attachOfficialAbhaCard($result);
|
|
}
|
|
|
|
public function abhaMobileGenerateOtp(array $payload): array
|
|
{
|
|
// txnId is optional for standalone mobile lookup, but required when
|
|
// updating the communication mobile during Aadhaar enrolment.
|
|
// Gateway handles RSA encryption and M3 format conversion internally.
|
|
$body = [
|
|
'mobile' => (string) ($payload['mobile'] ?? $payload['loginId'] ?? ''),
|
|
];
|
|
$txnId = trim((string) ($payload['txnId'] ?? $payload['txn_id'] ?? ''));
|
|
if ($txnId !== '') {
|
|
$body['txnId'] = $txnId;
|
|
}
|
|
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;
|
|
}
|
|
|
|
$result = $this->post('/v3/abha/mobile/verify-otp', $body);
|
|
$authResult = strtolower(trim((string) ($result['authResult'] ?? $result['data']['authResult'] ?? $result['auth_result'] ?? $result['data']['auth_result'] ?? '')));
|
|
$status = strtolower(trim((string) ($result['status'] ?? $result['data']['status'] ?? '')));
|
|
if ($authResult === 'failed' || in_array($status, ['failed', 'failure', 'error'], true)) {
|
|
$err = trim((string) ($result['message'] ?? $result['data']['message'] ?? $result['error_text'] ?? 'Please enter a valid OTP. Entered OTP is either expired or incorrect.'));
|
|
return array_merge($result, [
|
|
'ok' => 0,
|
|
'error_text' => $err !== '' ? $err : 'Please enter a valid OTP. Entered OTP is either expired or incorrect.',
|
|
'message' => $err !== '' ? $err : 'Please enter a valid OTP. Entered OTP is either expired or incorrect.',
|
|
]);
|
|
}
|
|
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
|
|
return $result;
|
|
}
|
|
|
|
return $this->attachOfficialAbhaCard($result);
|
|
}
|
|
|
|
public function abhaEnrolMobileRequestOtp(array $payload): array
|
|
{
|
|
$body = [
|
|
'txnId' => (string) ($payload['txnId'] ?? $payload['txn_id'] ?? ''),
|
|
'mobile' => (string) ($payload['mobile'] ?? ''),
|
|
];
|
|
if ($this->hfrId !== '') {
|
|
$body['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
return $this->post('/v3/abha/enrol/mobile/request-otp', $body);
|
|
}
|
|
|
|
public function abhaEnrolMobileVerifyOtp(array $payload): array
|
|
{
|
|
$body = [
|
|
'txnId' => (string) ($payload['txnId'] ?? $payload['txn_id'] ?? ''),
|
|
'otp' => (string) ($payload['otp'] ?? ''),
|
|
];
|
|
$mobile = trim((string) ($payload['mobile'] ?? ''));
|
|
if ($mobile !== '') {
|
|
$body['mobile'] = $mobile;
|
|
}
|
|
if ($this->hfrId !== '') {
|
|
$body['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
$result = $this->post('/v3/abha/enrol/mobile/verify-otp', $body);
|
|
$authResult = strtolower(trim((string) ($result['authResult'] ?? $result['data']['authResult'] ?? $result['auth_result'] ?? $result['data']['auth_result'] ?? '')));
|
|
$status = strtolower(trim((string) ($result['status'] ?? $result['data']['status'] ?? '')));
|
|
if ($authResult === 'failed' || in_array($status, ['failed', 'failure', 'error'], true)) {
|
|
$err = trim((string) ($result['message'] ?? $result['data']['message'] ?? $result['error_text'] ?? 'Please enter a valid OTP. Entered OTP is either expired or incorrect.'));
|
|
return array_merge($result, [
|
|
'ok' => 0,
|
|
'error_text' => $err !== '' ? $err : 'Please enter a valid OTP. Entered OTP is either expired or incorrect.',
|
|
'message' => $err !== '' ? $err : 'Please enter a valid OTP. Entered OTP is either expired or incorrect.',
|
|
]);
|
|
}
|
|
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
|
|
return $result;
|
|
}
|
|
|
|
return $this->attachOfficialAbhaCard($result);
|
|
}
|
|
|
|
public function abhaLoginSelectAccount(array $payload): array
|
|
{
|
|
$abhaNumber = (string) ($payload['abha_number'] ?? $payload['abhaNumber'] ?? $payload['ABHANumber'] ?? '');
|
|
$abhaAddress = (string) ($payload['abha_address'] ?? $payload['abhaAddress'] ?? $payload['preferredAbhaAddress'] ?? '');
|
|
$txnId = (string) ($payload['txnId'] ?? $payload['txn_id'] ?? '');
|
|
$token = (string) ($payload['token'] ?? '');
|
|
|
|
$body = [
|
|
'txnId' => $txnId,
|
|
'txn_id' => $txnId,
|
|
'token' => $token,
|
|
'abha_number' => $abhaNumber,
|
|
'abhaNumber' => $abhaNumber,
|
|
'ABHANumber' => $abhaNumber,
|
|
'abha_address' => $abhaAddress,
|
|
'abhaAddress' => $abhaAddress,
|
|
'preferredAbhaAddress' => $abhaAddress,
|
|
];
|
|
if ($this->hfrId !== '' && empty($body['hfr_id'])) {
|
|
$body['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
$result = $this->post('/v3/abha/login/select-account', $body);
|
|
if ((empty($result['ok']) || (int) $result['ok'] !== 1) && (int) ($result['http_code'] ?? 0) === 404) {
|
|
$result = $this->post('/v3/abha/mobile/select-account', $body);
|
|
}
|
|
|
|
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
|
|
return $result;
|
|
}
|
|
|
|
return $this->attachOfficialAbhaCard($result);
|
|
}
|
|
|
|
public function abhaAddressSuggestions(array $payload): array
|
|
{
|
|
$txnId = (string) ($payload['txn_id'] ?? $payload['txnId'] ?? '');
|
|
|
|
$result = $this->get('/v3/abha/suggestions', ['txnId' => $txnId]);
|
|
|
|
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
|
|
return $result;
|
|
}
|
|
|
|
$result['suggestions'] = $this->normalizeAbhaAddressSuggestions($result);
|
|
$result['bridge_mock'] = $result['suggestions'] === [] && $this->isMockBridgeResponse($result);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* The Bridge answers stubbed endpoints with {"ok":1,"mode":"test"} and no real
|
|
* payload; treating that as success would report false results to the operator.
|
|
*
|
|
* @param array<string, mixed> $result
|
|
*/
|
|
private function isMockBridgeResponse(array $result): bool
|
|
{
|
|
if (strtolower(trim((string) ($result['mode'] ?? ''))) === 'test') {
|
|
return true;
|
|
}
|
|
|
|
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
|
|
|
|
return stripos((string) ($data['message'] ?? ''), 'mock') !== false;
|
|
}
|
|
|
|
public function abhaSetAddress(array $payload): array
|
|
{
|
|
$txnId = (string) ($payload['txn_id'] ?? $payload['txnId'] ?? '');
|
|
$address = (string) ($payload['abha_address'] ?? $payload['abhaAddress'] ?? '');
|
|
// ABDM expects the handle without the @provider suffix.
|
|
$address = explode('@', $address)[0];
|
|
$body = [
|
|
'txnId' => $txnId,
|
|
'abhaAddress' => $address,
|
|
'preferred' => 1,
|
|
];
|
|
if ($this->hfrId !== '') {
|
|
$body['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
$result = $this->post('/v3/abha/set-address', $body);
|
|
|
|
if (! empty($result['ok']) && (int) $result['ok'] === 1) {
|
|
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
|
|
$preferred = trim((string) ($data['preferredAbhaAddress'] ?? $data['preferred_abha_address'] ?? $data['abhaAddress'] ?? ''));
|
|
if ($preferred !== '') {
|
|
$result['abha_address'] = $preferred;
|
|
}
|
|
$result['bridge_mock'] = $preferred === '' && $this->isMockBridgeResponse($result);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $result
|
|
* @return string[]
|
|
*/
|
|
private function normalizeAbhaAddressSuggestions(array $result): array
|
|
{
|
|
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
|
|
|
|
$raw = [];
|
|
foreach ([
|
|
$result['suggestions'] ?? null,
|
|
$result['abhaAddressList'] ?? null,
|
|
$result['abha_address_list'] ?? null,
|
|
$data['suggestions'] ?? null,
|
|
$data['abhaAddressList'] ?? null,
|
|
$data['abha_address_list'] ?? null,
|
|
] as $candidate) {
|
|
if (is_array($candidate) && $candidate !== []) {
|
|
$raw = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$addresses = [];
|
|
foreach ($raw as $entry) {
|
|
$value = is_array($entry)
|
|
? (string) ($entry['abhaAddress'] ?? $entry['abha_address'] ?? $entry['address'] ?? $entry['value'] ?? '')
|
|
: (string) $entry;
|
|
$value = trim($value);
|
|
if ($value !== '' && ! in_array($value, $addresses, true)) {
|
|
$addresses[] = $value;
|
|
}
|
|
}
|
|
|
|
return $addresses;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Consent
|
|
// -------------------------------------------------------------------------
|
|
|
|
public function requestConsent(
|
|
int $patientId,
|
|
string $abhaId,
|
|
string $purposeCode,
|
|
string $expiresAt,
|
|
string $consentHandle,
|
|
array $rawPayload = []
|
|
): array {
|
|
[$safeFrom, $safeTo] = $this->buildSafeConsentDateRange();
|
|
$body = [
|
|
'patient_abha' => $abhaId,
|
|
'purpose' => $purposeCode !== '' ? $purposeCode : 'TREATMENT',
|
|
'hi_types' => $rawPayload['hi_types'] ?? ['OPConsultation'],
|
|
'date_range_from' => (string) ($rawPayload['date_range_from'] ?? $safeFrom),
|
|
'date_range_to' => $this->clampUtcIsoToSafePast((string) ($rawPayload['date_range_to'] ?? $safeTo)),
|
|
];
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @return array{0:string,1:string}
|
|
*/
|
|
private function buildSafeConsentDateRange(): array
|
|
{
|
|
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
|
$safeTo = $nowUtc->modify('-120 seconds');
|
|
$safeFrom = $safeTo->modify('-365 days');
|
|
|
|
return [
|
|
$safeFrom->format('Y-m-d\TH:i:s.000\Z'),
|
|
$safeTo->format('Y-m-d\TH:i:s.000\Z'),
|
|
];
|
|
}
|
|
|
|
private function clampUtcIsoToSafePast(string $candidate): string
|
|
{
|
|
$candidate = trim($candidate);
|
|
$safeNowUtc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
|
|
->modify('-120 seconds');
|
|
$safeNowIso = $safeNowUtc->format('Y-m-d\TH:i:s.000\Z');
|
|
|
|
if ($candidate === '') {
|
|
return $safeNowIso;
|
|
}
|
|
|
|
try {
|
|
$candidateAt = new \DateTimeImmutable($candidate);
|
|
if ($candidateAt->getTimestamp() > $safeNowUtc->getTimestamp()) {
|
|
return $safeNowIso;
|
|
}
|
|
|
|
return $candidateAt
|
|
->setTimezone(new \DateTimeZone('UTC'))
|
|
->format('Y-m-d\TH:i:s.000\Z');
|
|
} catch (\Throwable $e) {
|
|
return $safeNowIso;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
$effectiveAbha = $abhaAddress !== '' ? $abhaAddress : $abhaId;
|
|
$visitDate = (string) ($data['visit_date'] ?? date('Y-m-d'));
|
|
|
|
$bundle = $data['record_data'] ?? $data['fhir_bundle'] ?? null;
|
|
if (empty($bundle) && $careContextReference !== '') {
|
|
try {
|
|
$db = \Config\Database::connect();
|
|
if ($db->tableExists('health_records')) {
|
|
$hrRow = $db->table('health_records')
|
|
->where('care_context_reference', $careContextReference)
|
|
->orderBy('id', 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
$raw = $hrRow['record_data'] ?? $hrRow['fhir_bundle'] ?? null;
|
|
if (! empty($raw)) {
|
|
$dec = json_decode((string) $raw, true);
|
|
if (is_array($dec) && ! empty($dec) && isset($dec['resourceType'])) {
|
|
$bundle = $dec;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
if (! empty($bundle) && is_array($bundle)) {
|
|
// When a FHIR bundle is attached, push to /api/v3/records/push so the bridge
|
|
// validates the FHIR resources and holds the bundle for on-demand patient fetch.
|
|
$patientRef = trim((string) ($data['patient_ref'] ?? $data['patient_code'] ?? $data['local_patient_id'] ?? $data['patient_id'] ?? ''));
|
|
$recordPushPayload = [
|
|
'hfr_id' => $this->hfrId,
|
|
'patient_id' => (string) ($data['patient_id'] ?? ''),
|
|
'patient_ref' => $patientRef,
|
|
'local_patient_id' => $patientRef,
|
|
'patient_name' => $patientName,
|
|
'abha_address' => $effectiveAbha,
|
|
'care_context_reference' => $careContextReference,
|
|
'care_context_display' => $careContextDisplay,
|
|
'hi_type' => $hiType,
|
|
'visit_date' => $visitDate,
|
|
'fhir_bundle' => $bundle,
|
|
'record_data' => $bundle,
|
|
];
|
|
if ($this->bridgeHospitalId !== '') {
|
|
$recordPushPayload['hospital_id'] = $this->bridgeHospitalId;
|
|
}
|
|
if ($abhaId !== '') {
|
|
$recordPushPayload['abha_id'] = $abhaId;
|
|
}
|
|
if (! empty($data['skip_auto_link'])) {
|
|
$recordPushPayload['skip_auto_link'] = true;
|
|
}
|
|
|
|
$res = $this->post('/v3/records/push', $recordPushPayload);
|
|
return self::normalizePushRecordResponse($res);
|
|
}
|
|
|
|
// Care-context only metadata registration
|
|
$notifyPayload = [
|
|
'hfr_id' => $this->hfrId,
|
|
'hospital_id' => $this->bridgeHospitalId,
|
|
'patient_id' => (string) ($data['patient_id'] ?? ''),
|
|
'patient_name' => $patientName,
|
|
'abha_address' => $effectiveAbha,
|
|
'care_context_reference' => $careContextReference,
|
|
'care_context_display' => $careContextDisplay,
|
|
'hi_type' => $hiType,
|
|
'date_of_record' => $visitDate,
|
|
];
|
|
|
|
if ($this->bridgeHospitalId !== '') {
|
|
$notifyPayload['hospital_id'] = $this->bridgeHospitalId;
|
|
}
|
|
if ($abhaId !== '') {
|
|
$notifyPayload['abha_id'] = $abhaId;
|
|
}
|
|
|
|
$res = $this->post('/v3/hip/link/notify', $notifyPayload);
|
|
|
|
$httpCode = (int) ($res['http_code'] ?? 0);
|
|
if (($res['ok'] ?? 0) === 1 || in_array($httpCode, [200, 201, 202], true)) {
|
|
return [
|
|
'ok' => 1,
|
|
'http_code' => 201,
|
|
'status' => 'linked',
|
|
'queue_id' => $res['request_id'] ?? ('NOTIFY-' . $careContextReference),
|
|
'record_id' => null,
|
|
'care_context_reference' => $careContextReference,
|
|
'care_context_display' => $careContextDisplay,
|
|
'hi_type' => $hiType,
|
|
'mode' => 'care_context_only',
|
|
'message' => 'Care context registered with ABDM Bridge (FHIR record retained in HMS for on-demand fetch)',
|
|
'response' => $res,
|
|
];
|
|
}
|
|
|
|
return self::normalizePushRecordResponse($res);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $result
|
|
* @return array<string,mixed>
|
|
*/
|
|
private static function normalizePushRecordResponse(array $result): array
|
|
{
|
|
$httpCode = (int) ($result['http_code'] ?? 0);
|
|
$error = $result['error'] ?? null;
|
|
$responseError = $result['response']['error'] ?? null;
|
|
$dataError = $result['data']['error'] ?? null;
|
|
$errorCode = strtoupper(trim((string) (
|
|
$result['error_code']
|
|
?? (is_array($error) ? ($error['code'] ?? null) : $error)
|
|
?? ($result['response']['error_code'] ?? null)
|
|
?? (is_array($responseError) ? ($responseError['code'] ?? null) : $responseError)
|
|
?? ($result['data']['error_code'] ?? null)
|
|
?? (is_array($dataError) ? ($dataError['code'] ?? null) : $dataError)
|
|
?? ''
|
|
)));
|
|
$duplicate = $httpCode === 409 && $errorCode === 'DUPLICATE_RECORD';
|
|
|
|
$recordId = self::firstPositiveInteger([
|
|
$result['record_id'] ?? null,
|
|
$result['existing_record_id'] ?? null,
|
|
$result['response']['record_id'] ?? null,
|
|
$result['response']['existing_record_id'] ?? null,
|
|
$result['data']['record_id'] ?? null,
|
|
$result['data']['existing_record_id'] ?? null,
|
|
]);
|
|
$queueId = self::firstNonEmptyString([
|
|
$result['queue_id'] ?? null,
|
|
$result['existing_queue_id'] ?? null,
|
|
$result['response']['queue_id'] ?? null,
|
|
$result['response']['existing_queue_id'] ?? null,
|
|
$result['data']['queue_id'] ?? null,
|
|
$result['data']['existing_queue_id'] ?? null,
|
|
]);
|
|
$firstPushedAt = self::firstNonEmptyString([
|
|
$result['first_pushed_at'] ?? null,
|
|
$result['response']['first_pushed_at'] ?? null,
|
|
$result['data']['first_pushed_at'] ?? null,
|
|
]);
|
|
|
|
if ($recordId > 0) {
|
|
$result['record_id'] = $recordId;
|
|
}
|
|
if ($queueId !== '') {
|
|
$result['queue_id'] = $queueId;
|
|
}
|
|
if ($firstPushedAt !== '') {
|
|
$result['first_pushed_at'] = $firstPushedAt;
|
|
}
|
|
$result['error_code'] = $errorCode !== '' ? $errorCode : ($result['error_code'] ?? null);
|
|
$result['duplicate'] = $duplicate ? 1 : 0;
|
|
$result['submitted'] = ($httpCode === 201 || $duplicate) ? 1 : 0;
|
|
|
|
if ($httpCode === 201 || $duplicate) {
|
|
$result['ok'] = 1;
|
|
return $result;
|
|
}
|
|
|
|
if ($httpCode === 422) {
|
|
$result['ok'] = 0;
|
|
$message = trim((string) ($result['message'] ?? $result['error_text'] ?? 'Validation failed'));
|
|
$details = [];
|
|
foreach ((array) ($result['errors'] ?? []) as $error) {
|
|
$details[] = is_array($error)
|
|
? trim(implode(' ', array_filter([$error['field'] ?? '', $error['message'] ?? ''])))
|
|
: trim((string) $error);
|
|
}
|
|
$details = array_values(array_filter($details));
|
|
$result['error_text'] = $message . ($details !== [] ? ' | ' . implode(' ; ', $details) : '');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/** @param array<int,mixed> $values */
|
|
private static function firstPositiveInteger(array $values): int
|
|
{
|
|
foreach ($values as $value) {
|
|
if ((int) $value > 0) {
|
|
return (int) $value;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/** @param array<int,mixed> $values */
|
|
private static function firstNonEmptyString(array $values): string
|
|
{
|
|
foreach ($values as $value) {
|
|
if (trim((string) $value) !== '') {
|
|
return trim((string) $value);
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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 facilityQrCode(): array
|
|
{
|
|
return $this->get('/v3/facility/qr-code');
|
|
}
|
|
|
|
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']);
|
|
}
|
|
}
|
|
|
|
if ($this->hfrId !== '' && empty($payload['hfr_id'])) {
|
|
$payload['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
return $this->post('/v3/opd/token', $payload);
|
|
}
|
|
|
|
public function opdTokenUpdateStatus(int $tokenId, string $status): array
|
|
{
|
|
$body = ['status' => $status];
|
|
if ($this->hfrId !== '') {
|
|
$body['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
return $this->patch('/v3/opd/token/' . $tokenId, $body);
|
|
}
|
|
|
|
public function opdRunningTokenStatus(): array
|
|
{
|
|
return $this->get('/v3/opd/running-token-status');
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Immunization Master Data
|
|
// -------------------------------------------------------------------------
|
|
|
|
public function immunizationUipVersion(): array
|
|
{
|
|
return $this->get('/v3/master-data/immunization/uip/version');
|
|
}
|
|
|
|
public function immunizationUipSchedule(): array
|
|
{
|
|
return $this->get('/v3/master-data/immunization/uip');
|
|
}
|
|
|
|
public function immunizationUipChanges(string $sinceVersion): array
|
|
{
|
|
$query = [];
|
|
if ($sinceVersion !== '') {
|
|
$query['since_version'] = $sinceVersion;
|
|
}
|
|
|
|
return $this->get('/v3/master-data/immunization/uip/changes', $query);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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 (! empty($payload['abha_number'])) {
|
|
$cleaned = preg_replace('/\D/', '', (string) $payload['abha_number']);
|
|
if (strlen($cleaned) === 14 && strpos((string) $payload['abha_number'], '@') === false) {
|
|
$payload['abha_number'] = $cleaned;
|
|
} else {
|
|
unset($payload['abha_number']);
|
|
}
|
|
}
|
|
|
|
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 (! empty($payload['abha_number'])) {
|
|
$cleaned = preg_replace('/\D/', '', (string) $payload['abha_number']);
|
|
if (strlen($cleaned) === 14 && strpos((string) $payload['abha_number'], '@') === false) {
|
|
$payload['abha_number'] = $cleaned;
|
|
} else {
|
|
unset($payload['abha_number']);
|
|
}
|
|
}
|
|
|
|
if ($this->hfrId !== '' && empty($payload['hfr_id'])) {
|
|
$payload['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
$hasLinkId = ! empty($payload['link_token_id']);
|
|
$delaysSec = $this->linkTokenRetryDelays;
|
|
$maxAttempts = $hasLinkId ? count($delaysSec) + 1 : 1;
|
|
|
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
|
$result = $this->post('/v3/hip/link/carecontext', $payload);
|
|
|
|
if (($result['ok'] ?? 0) === 1) {
|
|
return $result;
|
|
}
|
|
|
|
// Check if error is because ABDM callback hasn't populated link_token yet on the bridge
|
|
$err = strtolower((string) ($result['error_text'] ?? $result['error'] ?? ''));
|
|
$isTokenPending = $hasLinkId && (
|
|
strpos($err, 'link_token') !== false &&
|
|
(strpos($err, 'required') !== false || strpos($err, 'link-token first') !== false)
|
|
);
|
|
|
|
if (! $isTokenPending || $attempt === $maxAttempts) {
|
|
if ($isTokenPending) {
|
|
$result['error_text'] = 'ABDM authorization token callback is still pending from the gateway. Please retry in a few seconds.';
|
|
$result['error'] = $result['error_text'];
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
$sleepSec = $delaysSec[$attempt - 1] ?? 2.0;
|
|
usleep((int) ($sleepSec * 1000000));
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public function drugsSyncPack(int $page = 1, int $limit = 500): array
|
|
{
|
|
$query = [
|
|
'page' => max(1, $page),
|
|
'limit' => max(1, min(1000, $limit)),
|
|
];
|
|
if ($this->hfrId !== '') {
|
|
$query['hfr_id'] = $this->hfrId;
|
|
}
|
|
|
|
return $this->get('/v3/drugs/sync-pack', $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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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);
|
|
}
|
|
}
|