feat(abdm): implement M2 gateway contract endpoints and live test tooling

This commit is contained in:
DevSoftTech Server 2026-06-24 04:50:03 +05:30
parent 985d476bc1
commit fbaa985857
8 changed files with 1396 additions and 49 deletions

View File

@ -624,6 +624,12 @@ $routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest', ['fi
$routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback');
$routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevokedCallback');
$routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback');
// HMS M2 adapter endpoints (Gateway -> HMS) - keep lowercase v1 path
$routes->get('api/v1/abdm/gateway/health', 'AbdmGateway::m2GatewayHealth');
$routes->post('api/v1/abdm/gateway/discovery/care-contexts', 'AbdmGateway::m2DiscoveryCareContexts');
$routes->post('api/v1/abdm/gateway/health-information/fetch', 'AbdmGateway::m2HealthInformationFetch');
$routes->post('api/v1/abdm/gateway/consent/upsert', 'AbdmGateway::m2ConsentUpsert');
$routes->post('api/v1/abdm/gateway/link/status', 'AbdmGateway::m2LinkStatus');
// ABDM M2 compatibility endpoints (Gateway -> HMS)
$routes->post('records/discover', 'AbdmGateway::recordsDiscover');
$routes->get('records/fetch/(:any)', 'AbdmGateway::recordsFetch/$1');

View File

@ -101,6 +101,327 @@ class AbdmGateway extends BaseController
}
}
// =========================================================================
// HMS M2 Adapter Endpoints (Gateway -> HMS)
// =========================================================================
/**
* GET /api/v1/abdm/gateway/health
*/
public function m2GatewayHealth()
{
$authFailure = $this->validateGatewayToHmsAuth();
if ($authFailure !== null) {
return $authFailure;
}
return $this->response->setJSON([
'ok' => 1,
'service' => 'hms-abdm-adapter',
'time' => gmdate('Y-m-d\TH:i:s\Z'),
]);
}
/**
* POST /api/v1/abdm/gateway/discovery/care-contexts
*/
public function m2DiscoveryCareContexts()
{
$authFailure = $this->validateGatewayToHmsAuth();
if ($authFailure !== null) {
return $authFailure;
}
$payload = $this->request->getJSON(true);
if (! is_array($payload)) {
$payload = [];
}
$requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id')));
$abhaId = trim((string) ($payload['abha_id'] ?? ''));
$abhaAddress = trim((string) ($payload['abha_address'] ?? ''));
if ($abhaId === '' && $abhaAddress === '') {
return $this->response->setStatusCode(400)->setJSON([
'ok' => 0,
'error' => 'MISSING_FIELD',
'message' => 'abha_id or abha_address is required',
'request_id' => $requestId,
]);
}
if (! $this->db->tableExists('patient_records')) {
return $this->response->setJSON(['ok' => 1, 'patient' => null, 'care_contexts' => []]);
}
$abhaLookup = $abhaAddress !== '' ? $abhaAddress : $abhaId;
$rows = $this->db->table('patient_records')
->select('patient_id, abha_id, record_type, created_at, consent_id')
->where('abha_id', $abhaLookup)
->where('status', 'ACTIVE')
->orderBy('record_id', 'DESC')
->limit(200)
->get()
->getResultArray();
if (empty($rows)) {
return $this->response->setJSON(['ok' => 1, 'patient' => null, 'care_contexts' => []]);
}
$contexts = [];
foreach ($rows as $row) {
$patientId = (int) ($row['patient_id'] ?? 0);
$createdAt = trim((string) ($row['created_at'] ?? ''));
$datePart = $createdAt !== '' ? date('Y-m-d', strtotime($createdAt)) : date('Y-m-d');
$recordType = strtoupper(trim((string) ($row['record_type'] ?? 'OTHER')));
$ccRef = $recordType . '-' . $patientId . '-' . str_replace('-', '', $datePart);
$hiType = $this->mapPatientRecordTypeToHiType($recordType);
$contexts[$ccRef] = [
'referenceNumber' => $ccRef,
'display' => $recordType . ' - ' . $datePart,
'hiType' => $hiType,
];
}
$patientDisplay = trim((string) ($payload['patient']['name'] ?? ''));
if ($patientDisplay === '') {
$patientDisplay = 'ABHA Patient';
}
$contextsOut = array_values($contexts);
$hiTypeSummary = ! empty($contextsOut)
? (string) ($contextsOut[0]['hiType'] ?? 'HealthDocumentRecord')
: 'HealthDocumentRecord';
$this->getAuditService()->log([
'action' => 'discovery_records',
'entity_type' => 'patient_records',
'abha_id' => $abhaLookup,
'patient_id' => (int) ($rows[0]['patient_id'] ?? 0),
'request' => $payload,
'response' => ['count' => count($contextsOut)],
'outcome' => 'success',
'transaction_id' => (string) ($payload['transaction_id'] ?? ''),
]);
return $this->response->setJSON([
'ok' => 1,
'patient' => [
'referenceNumber' => $abhaLookup,
'display' => $patientDisplay,
'count' => count($contextsOut),
'hiType' => $hiTypeSummary,
],
'care_contexts' => $contextsOut,
]);
}
/**
* POST /api/v1/abdm/gateway/health-information/fetch
*/
public function m2HealthInformationFetch()
{
$authFailure = $this->validateGatewayToHmsAuth();
if ($authFailure !== null) {
return $authFailure;
}
$payload = $this->request->getJSON(true);
if (! is_array($payload)) {
$payload = [];
}
$requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id')));
$abhaId = trim((string) ($payload['abha_id'] ?? ''));
$abhaAddress = trim((string) ($payload['abha_address'] ?? ''));
$abhaLookup = $abhaAddress !== '' ? $abhaAddress : $abhaId;
if ($abhaLookup === '') {
return $this->response->setStatusCode(400)->setJSON([
'ok' => 0,
'error' => 'MISSING_FIELD',
'message' => 'abha_id or abha_address is required',
'request_id' => $requestId,
]);
}
if (! $this->db->tableExists('patient_records')) {
return $this->response->setJSON(['ok' => 1, 'entries' => []]);
}
$requestedRefs = [];
$incomingRefs = $payload['care_context_references'] ?? [];
if (is_array($incomingRefs)) {
foreach ($incomingRefs as $ref) {
$v = trim((string) $ref);
if ($v !== '') {
$requestedRefs[] = $v;
}
}
}
$rows = $this->db->table('patient_records')
->select('record_id, patient_id, abha_id, consent_id, record_type, fhir_resource, created_at')
->where('abha_id', $abhaLookup)
->where('status', 'ACTIVE')
->orderBy('record_id', 'DESC')
->limit(300)
->get()
->getResultArray();
$entries = [];
foreach ($rows as $row) {
$patientId = (int) ($row['patient_id'] ?? 0);
$recordType = strtoupper(trim((string) ($row['record_type'] ?? 'OTHER')));
$createdAt = trim((string) ($row['created_at'] ?? ''));
$datePart = $createdAt !== '' ? date('Y-m-d', strtotime($createdAt)) : date('Y-m-d');
$ccRef = $recordType . '-' . $patientId . '-' . str_replace('-', '', $datePart);
if (! empty($requestedRefs) && ! in_array($ccRef, $requestedRefs, true)) {
continue;
}
$fhir = json_decode((string) ($row['fhir_resource'] ?? ''), true);
if (! is_array($fhir)) {
continue;
}
$entries[] = [
'careContextReference' => $ccRef,
'media' => 'application/fhir+json',
'fhir' => $fhir,
];
}
$this->getAuditService()->log([
'action' => 'fetch_record',
'entity_type' => 'patient_records',
'abha_id' => $abhaLookup,
'request' => $payload,
'response' => ['count' => count($entries)],
'outcome' => 'success',
'consent_id' => (string) ($payload['consent_id'] ?? ''),
'transaction_id' => (string) ($payload['transaction_id'] ?? ''),
]);
return $this->response->setJSON([
'ok' => 1,
'entries' => $entries,
]);
}
/**
* POST /api/v1/abdm/gateway/consent/upsert
*/
public function m2ConsentUpsert()
{
$authFailure = $this->validateGatewayToHmsAuth();
if ($authFailure !== null) {
return $authFailure;
}
$payload = $this->request->getJSON(true);
if (! is_array($payload)) {
$payload = [];
}
$requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id')));
$patientId = (int) ($payload['patient_id'] ?? 0);
$abhaId = trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? ''));
$consentId = trim((string) ($payload['consent_id'] ?? ''));
$status = strtoupper(trim((string) ($payload['status'] ?? '')));
$purpose = trim((string) ($payload['purpose'] ?? ''));
$expiresAt = trim((string) ($payload['date_range']['to'] ?? ''));
if ($abhaId === '' || $consentId === '' || $status === '') {
return $this->response->setStatusCode(400)->setJSON([
'ok' => 0,
'error' => 'MISSING_FIELD',
'message' => 'abha_id, consent_id and status are required',
'request_id' => $requestId,
]);
}
if ($this->db->tableExists('consent_logs')) {
$this->db->table('consent_logs')->insert([
'patient_id' => $patientId > 0 ? $patientId : 0,
'abha_id' => $abhaId,
'consent_id' => $consentId,
'purpose' => $purpose !== '' ? $purpose : null,
'status' => in_array($status, ['GRANTED', 'REVOKED', 'EXPIRED'], true) ? $status : 'EXPIRED',
'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
'expires_at' => $expiresAt !== '' ? $expiresAt : null,
]);
}
return $this->response->setJSON(['ok' => 1]);
}
/**
* POST /api/v1/abdm/gateway/link/status
*/
public function m2LinkStatus()
{
$authFailure = $this->validateGatewayToHmsAuth();
if ($authFailure !== null) {
return $authFailure;
}
$payload = $this->request->getJSON(true);
if (! is_array($payload)) {
$payload = [];
}
$requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id')));
$abhaId = trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? ''));
$status = strtolower(trim((string) ($payload['status'] ?? '')));
$careContextRef = trim((string) ($payload['care_context_reference'] ?? ''));
$linkedAt = trim((string) ($payload['linked_at'] ?? ''));
if ($abhaId === '' || $careContextRef === '') {
return $this->response->setStatusCode(400)->setJSON([
'ok' => 0,
'error' => 'MISSING_FIELD',
'message' => 'abha_id and care_context_reference are required',
'request_id' => $requestId,
]);
}
if ($this->db->tableExists('record_links')) {
$existing = $this->db->table('record_links')
->where('care_context_reference', $careContextRef)
->where('abha_id', $abhaId)
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$linkStatus = $status === 'linked' ? 'linked' : 'failed';
$now = Time::now('Asia/Kolkata')->toDateTimeString();
if (! empty($existing)) {
$this->db->table('record_links')->where('id', (int) $existing['id'])->update([
'link_status' => $linkStatus,
'linked_at' => $linkedAt !== '' ? $linkedAt : $now,
'updated_at' => $now,
'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
} else {
$this->db->table('record_links')->insert([
'abha_id' => $abhaId,
'care_context_reference' => $careContextRef,
'link_status' => $linkStatus,
'linked_at' => $linkedAt !== '' ? $linkedAt : $now,
'created_at' => $now,
'updated_at' => $now,
'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
}
}
return $this->response->setJSON(['ok' => 1]);
}
public function consentRequest()
{
if (! $this->request->isAJAX()) {
@ -330,6 +651,17 @@ class AbdmGateway extends BaseController
}
}
$this->syncM2ConsentLog(
(int) ($existing['patient_id'] ?? 0),
trim((string) ($existing['abha_id'] ?? '')) !== ''
? trim((string) ($existing['abha_id'] ?? ''))
: trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? '')),
$incomingConsentId !== '' ? $incomingConsentId : $handle,
(string) ($existing['purpose_code'] ?? ''),
$incomingStatus,
$expiresAt !== '' ? $expiresAt : ((string) ($existing['expires_at'] ?? ''))
);
return $this->response->setJSON([
'ok' => 1,
'consent_handle' => $handle,
@ -2018,6 +2350,15 @@ class AbdmGateway extends BaseController
'outcome' => 'success',
]);
$this->syncM2ConsentLog(
(int) ($existing['patient_id'] ?? 0),
$abhaId,
$consentHandle,
(string) ($existing['purpose_code'] ?? ''),
'revoked',
$revokedAt
);
return $this->response->setJSON(['ok' => 1, 'consent_handle' => $consentHandle, 'status' => 'revoked']);
}
@ -2948,6 +3289,136 @@ class AbdmGateway extends BaseController
return null;
}
private function validateGatewayToHmsAuth()
{
$token = $this->resolveGatewayToHmsToken();
$authHeader = trim((string) $this->request->getHeaderLine('Authorization'));
$incomingBearer = '';
if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m) === 1) {
$incomingBearer = trim((string) ($m[1] ?? ''));
}
if ($token === '' || $incomingBearer === '' || ! hash_equals($token, $incomingBearer)) {
return $this->response->setStatusCode(401)->setJSON([
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Invalid Authorization bearer token',
'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')),
]);
}
$signatureFailure = $this->validateGatewayToHmsSignature();
if ($signatureFailure !== null) {
return $signatureFailure;
}
$timestampFailure = $this->validateGatewayToHmsTimestamp();
if ($timestampFailure !== null) {
return $timestampFailure;
}
return null;
}
private function validateGatewayToHmsSignature()
{
$secret = trim((string) ($this->readRuntimeSetting('GATEWAY_TO_HMS_HMAC_SECRET') ?: $this->readRuntimeSetting('EKA_WEBHOOK_SECRET')));
if ($secret === '') {
return null;
}
$signature = trim((string) ($this->request->getHeaderLine('X-Eka-Signature') ?: ''));
if ($signature === '') {
return $this->response->setStatusCode(401)->setJSON([
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Missing X-Eka-Signature header',
'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')),
]);
}
$signature = strtolower($signature);
if (str_starts_with($signature, 'sha256=')) {
$signature = substr($signature, 7);
}
$rawBody = (string) $this->request->getBody();
$expected = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) {
return $this->response->setStatusCode(401)->setJSON([
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Invalid request signature',
'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')),
]);
}
return null;
}
private function validateGatewayToHmsTimestamp()
{
$timestamp = trim((string) $this->request->getHeaderLine('X-Timestamp'));
if ($timestamp === '') {
return null;
}
try {
$ts = new \DateTimeImmutable($timestamp);
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$diff = abs($now->getTimestamp() - $ts->setTimezone(new \DateTimeZone('UTC'))->getTimestamp());
if ($diff > 300) {
return $this->response->setStatusCode(401)->setJSON([
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Request timestamp outside allowed skew',
'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')),
]);
}
} catch (\Throwable) {
return $this->response->setStatusCode(401)->setJSON([
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Invalid X-Timestamp value',
'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')),
]);
}
return null;
}
private function resolveGatewayToHmsToken(): string
{
$candidates = [
'GATEWAY_TO_HMS_TOKEN',
'ABDM_GATEWAY_TO_HMS_TOKEN',
'EKA_GATEWAY_TOKEN',
'EATRIA_BRIDGE_TOKEN',
'ABDM_BRIDGE_TOKEN',
];
foreach ($candidates as $name) {
$value = trim((string) $this->readRuntimeSetting($name));
if ($value !== '') {
return preg_replace('/^Bearer\s+/i', '', $value);
}
}
return '';
}
private function mapPatientRecordTypeToHiType(string $recordType): string
{
return match (strtoupper(trim($recordType))) {
'OPD' => 'OPConsultRecord',
'IPD' => 'HealthDocumentRecord',
'LAB' => 'DiagnosticReportRecord',
'DISCHARGE' => 'DischargeSummaryRecord',
'MLC' => 'HealthDocumentRecord',
default => 'HealthDocumentRecord',
};
}
private function readRuntimeSetting(string $name): string
{
$envValue = getenv($name);
@ -3061,13 +3532,143 @@ class AbdmGateway extends BaseController
}
$this->db->table('health_records')->insert($insert);
$insertId = (int) $this->db->insertID();
return (int) $this->db->insertID();
$this->mirrorHealthRecordToPatientRecords($data);
return $insertId;
} catch (\Throwable) {
return 0;
}
}
/**
* Mirror an HMS health record into patient_records for M2 source-of-truth APIs.
* Fail-open by design.
*
* @param array<string, mixed> $data
*/
private function mirrorHealthRecordToPatientRecords(array $data): void
{
try {
if (! $this->db->tableExists('patient_records')) {
return;
}
$patientId = (int) ($data['patient_id'] ?? 0);
$abhaId = trim((string) ($data['abha_id'] ?? ''));
$fhirBundle = trim((string) ($data['fhir_bundle'] ?? ''));
if ($patientId <= 0 || $abhaId === '' || $fhirBundle === '') {
return;
}
$decoded = json_decode($fhirBundle, true);
if (! is_array($decoded)) {
return;
}
$recordType = $this->resolvePatientRecordType(
(string) ($data['hi_type'] ?? ''),
(string) ($data['entity_type'] ?? '')
);
$expiryDate = $this->resolvePatientRecordExpiryDate(
$recordType,
(string) ($data['entity_type'] ?? ''),
(string) ($data['entity_id'] ?? '')
);
$this->db->table('patient_records')->insert([
'patient_id' => $patientId,
'abha_id' => $abhaId,
'consent_id' => trim((string) ($data['consent_handle'] ?? '')) ?: null,
'record_type' => $recordType,
'fhir_resource' => json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
'expiry_date' => $expiryDate,
'status' => 'ACTIVE',
]);
} catch (\Throwable) {
// Fail-open
}
}
private function resolvePatientRecordType(string $hiType, string $entityType = ''): string
{
$v = strtolower(trim($hiType));
if (str_contains($v, 'opconsult') || str_contains($v, 'prescription')) {
return 'OPD';
}
if (str_contains($v, 'diagnosticreport') || $entityType === 'lab') {
return 'LAB';
}
if (str_contains($v, 'discharge')) {
return 'DISCHARGE';
}
if ($entityType === 'ipd') {
return 'IPD';
}
return 'OTHER';
}
private function resolvePatientRecordExpiryDate(string $recordType, string $entityType = '', string $entityId = ''): ?string
{
if ($recordType === 'MLC') {
return null;
}
// OPD and LAB default to 3 years, IPD/DISCHARGE to 7 years.
$years = in_array($recordType, ['IPD', 'DISCHARGE'], true) ? 7 : 3;
if ($recordType === 'LAB' && strtolower(trim($entityType)) === 'ipd') {
$years = 7;
}
return date('Y-m-d', strtotime('+' . $years . ' years'));
}
private function syncM2ConsentLog(
int $patientId,
string $abhaId,
string $consentId,
string $purpose,
string $status,
string $expiresAt = ''
): void {
try {
if (! $this->db->tableExists('consent_logs')) {
return;
}
$mapped = match (strtolower(trim($status))) {
'approved', 'granted' => 'GRANTED',
'revoked' => 'REVOKED',
'expired' => 'EXPIRED',
default => '',
};
if ($mapped === '' || $patientId <= 0 || trim($abhaId) === '' || trim($consentId) === '') {
return;
}
$insert = [
'patient_id' => $patientId,
'abha_id' => trim($abhaId),
'consent_id' => trim($consentId),
'purpose' => trim($purpose) !== '' ? trim($purpose) : null,
'status' => $mapped,
'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
'expires_at' => trim($expiresAt) !== '' ? trim($expiresAt) : null,
];
$this->db->table('consent_logs')->insert($insert);
} catch (\Throwable) {
// Fail-open
}
}
/**
* Update a health_record's push status and txn_id after getting a connector response.
* Also creates a matching record_links row.

View File

@ -5349,12 +5349,18 @@ class Opd_prescription extends BaseController
try {
$gatewayUrl = base_url('AbdmGateway/share_prescription_bundle');
$cookieHeader = (string) $this->request->getHeaderLine('Cookie');
$handoffTimeoutSec = $this->resolveFhirGatewayHandoffTimeoutSec();
$client = service('curlrequest', [
'timeout' => 60,
'timeout' => $handoffTimeoutSec,
'connect_timeout' => 10,
'http_errors' => false,
]);
$response = null;
$attemptError = null;
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = $client->post($gatewayUrl, [
'headers' => array_filter([
'X-Requested-With' => 'XMLHttpRequest',
@ -5369,6 +5375,21 @@ class Opd_prescription extends BaseController
csrf_token() => csrf_hash(),
],
]);
$attemptError = null;
break;
} catch (\Throwable $attemptException) {
$attemptError = $attemptException;
if ($attempt < 2 && $this->isTransientFhirGatewayHandoffError($attemptException)) {
log_message('warning', '[fhir_bundle_submit] transient handoff error on attempt ' . $attempt . ' for OPD#' . $opdId . ': ' . $attemptException->getMessage());
continue;
}
throw $attemptException;
}
}
if ($response === null && $attemptError !== null) {
throw $attemptError;
}
$httpCode = (int) $response->getStatusCode();
$result = json_decode((string) $response->getBody(), true);
@ -5394,15 +5415,73 @@ class Opd_prescription extends BaseController
]);
} catch (\Throwable $e) {
log_message('error', '[fhir_bundle_submit] OPD#' . $opdId . ': ' . $e->getMessage());
$isTimeout = $this->isTransientFhirGatewayHandoffError($e);
$friendly = $isTimeout
? 'Gateway handoff timed out. Bridge is slow/unreachable right now. Please retry submit in a minute.'
: 'Gateway handoff failed: ' . $e->getMessage();
return $this->response->setJSON([
'ok' => 0,
'message' => 'Gateway handoff failed: ' . $e->getMessage(),
'message' => $friendly,
'retryable' => $isTimeout ? 1 : 0,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
}
private function resolveFhirGatewayHandoffTimeoutSec(): int
{
$timeout = 120;
if ($this->db->tableExists('hospital_setting')) {
$row = $this->db->table('hospital_setting')
->select('s_value')
->whereIn('s_name', ['ABDM_FHIR_SUBMIT_TIMEOUT_SECONDS', 'ABDM_GATEWAY_TIMEOUT_SECONDS'])
->where('s_value !=', '')
->orderBy('s_name', 'ASC')
->get(1)
->getRowArray();
if (! empty($row['s_value'])) {
$timeout = (int) $row['s_value'];
}
}
if ($timeout < 30) {
$timeout = 30;
}
if ($timeout > 240) {
$timeout = 240;
}
return $timeout;
}
private function isTransientFhirGatewayHandoffError(\Throwable $e): bool
{
$msg = strtolower(trim($e->getMessage()));
if ($msg === '') {
return false;
}
$needles = [
'timed out',
'operation timed out',
'curl error 28',
'failed to connect',
'connection reset',
'empty reply from server',
];
foreach ($needles as $needle) {
if (str_contains($msg, $needle)) {
return true;
}
}
return false;
}
public function fhir_complaint_recode()
{
if (! $this->request->isAJAX()) {

View File

@ -0,0 +1,217 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateM2HmsAbdmTables extends Migration
{
public function up(): void
{
$this->createPatientRecordsTable();
$this->createConsentLogsTable();
$this->createAuditLogsTable();
}
public function down(): void
{
$this->forge->dropTable('audit_logs', true);
$this->forge->dropTable('consent_logs', true);
$this->forge->dropTable('patient_records', true);
}
private function createPatientRecordsTable(): void
{
if ($this->hasTable('patient_records')) {
return;
}
$this->forge->addField([
'record_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'patient_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => false,
],
'abha_id' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => false,
],
'consent_id' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'record_type' => [
'type' => 'ENUM',
'constraint' => ['OPD', 'IPD', 'LAB', 'DISCHARGE', 'MLC', 'OTHER'],
'null' => false,
],
'fhir_resource' => [
'type' => 'JSON',
'null' => false,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'expiry_date' => [
'type' => 'DATE',
'null' => true,
],
'status' => [
'type' => 'ENUM',
'constraint' => ['ACTIVE', 'ARCHIVED'],
'default' => 'ACTIVE',
'null' => false,
],
]);
$this->forge->addKey('record_id', true);
$this->forge->addKey('patient_id');
$this->forge->addKey('abha_id');
$this->forge->addKey('consent_id');
$this->forge->addKey('record_type');
$this->forge->addKey('expiry_date');
$this->forge->addKey('status');
$this->forge->createTable('patient_records', true);
}
private function createConsentLogsTable(): void
{
if ($this->hasTable('consent_logs')) {
return;
}
$this->forge->addField([
'log_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'patient_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => false,
],
'abha_id' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => false,
],
'consent_id' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => false,
],
'purpose' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'status' => [
'type' => 'ENUM',
'constraint' => ['GRANTED', 'REVOKED', 'EXPIRED'],
'null' => false,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'expires_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('log_id', true);
$this->forge->addKey('patient_id');
$this->forge->addKey('abha_id');
$this->forge->addKey('consent_id');
$this->forge->addKey('status');
$this->forge->addKey('expires_at');
$this->forge->createTable('consent_logs', true);
}
private function createAuditLogsTable(): void
{
if ($this->hasTable('audit_logs')) {
return;
}
$this->forge->addField([
'audit_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'patient_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => false,
],
'abha_id' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => false,
],
'action' => [
'type' => 'ENUM',
'constraint' => ['DISCOVERY', 'FETCH', 'SHARE'],
'null' => false,
],
'consent_id' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'transaction_id' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'timestamp' => [
'type' => 'DATETIME',
'null' => true,
],
'details' => [
'type' => 'TEXT',
'null' => true,
],
]);
$this->forge->addKey('audit_id', true);
$this->forge->addKey('patient_id');
$this->forge->addKey('abha_id');
$this->forge->addKey('action');
$this->forge->addKey('consent_id');
$this->forge->addKey('transaction_id');
$this->forge->addKey('timestamp');
$this->forge->createTable('audit_logs', true);
}
private function hasTable(string $table): bool
{
try {
$row = $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getRowArray();
return ! empty($row);
} catch (\Throwable) {
return false;
}
}
}

View File

@ -33,41 +33,62 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$this->timeoutSec = (int) ($config->eatriaBridgeTimeoutSec ?? 30);
// DB (Admin Panel → ABDM Gateway Config) is the authoritative source for
// the token, URL, and HFR ID. Always read from DB so that saving a new key
// in HMS settings takes effect immediately without a server restart or
// .env change. Fall back to config/env only if DB is unavailable.
// the token, URL, and HFR ID. Initial load here; per-request refresh happens in httpCall().
$this->refreshRuntimeSettingsFromDb();
}
private function refreshRuntimeSettingsFromDb(): void
{
try {
$db = \Config\Database::connect();
if ($db->tableExists('hospital_setting')) {
if (! $db->tableExists('hospital_setting')) {
return;
}
$rows = $db->table('hospital_setting')
->select('s_name, s_value')
->whereIn('s_name', ['EATRIA_BRIDGE_TOKEN', 'ABDM_BRIDGE_TOKEN', 'EATRIA_BRIDGE_URL', 'ABDM_HFR_ID', 'H_HFR_ID'])
->whereIn('s_name', [
'EATRIA_BRIDGE_TOKEN',
'ABDM_BRIDGE_TOKEN',
'ABDM_GATEWAY_TOKEN',
'EATRIA_BRIDGE_API_KEY',
'EATRIA_BRIDGE_URL',
'ABDM_HFR_ID',
'H_HFR_ID',
'ABDM_HOSPITAL_HFR_ID',
])
->get()
->getResultArray();
$dbSettings = array_column($rows, 's_value', 's_name');
$dbToken = (string) ($dbSettings['EATRIA_BRIDGE_TOKEN'] ?? '');
if ($dbToken === '') {
// Backward compatibility: older setups may store the key under ABDM_BRIDGE_TOKEN.
$dbToken = (string) ($dbSettings['ABDM_BRIDGE_TOKEN'] ?? '');
}
$dbToken = trim((string) (
$dbSettings['EATRIA_BRIDGE_TOKEN']
?? $dbSettings['ABDM_BRIDGE_TOKEN']
?? $dbSettings['ABDM_GATEWAY_TOKEN']
?? $dbSettings['EATRIA_BRIDGE_API_KEY']
?? ''
));
if ($dbToken !== '') {
$this->token = $this->sanitizeBearerToken($dbToken);
}
if (! empty($dbSettings['EATRIA_BRIDGE_URL'])) {
$this->baseUrl = rtrim(trim($dbSettings['EATRIA_BRIDGE_URL']), '/');
}
$dbHfrId = trim((string) ($dbSettings['ABDM_HFR_ID'] ?? ''));
if ($dbHfrId === '') {
$dbHfrId = trim((string) ($dbSettings['H_HFR_ID'] ?? ''));
$dbUrl = trim((string) ($dbSettings['EATRIA_BRIDGE_URL'] ?? ''));
if ($dbUrl !== '') {
$this->baseUrl = rtrim($dbUrl, '/');
}
$dbHfrId = trim((string) (
$dbSettings['ABDM_HFR_ID']
?? $dbSettings['H_HFR_ID']
?? $dbSettings['ABDM_HOSPITAL_HFR_ID']
?? ''
));
if ($dbHfrId !== '') {
$this->hfrId = $dbHfrId;
}
}
} catch (\Throwable $e) {
// DB unavailable — continue with config/env values
log_message('warning', '[EAtriaBridge] refreshRuntimeSettingsFromDb failed: ' . $e->getMessage());
}
}
@ -190,6 +211,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
*/
private function httpCall(string $method, string $path, array $body = [], array $query = []): array
{
// Credentials can be rotated from settings without restarting PHP workers.
// Refresh before each call to avoid stale token/HFR causing 401/403.
$this->refreshRuntimeSettingsFromDb();
$url = $this->baseUrl . $path;
if ($query !== []) {
$url .= '?' . http_build_query($query);
@ -203,6 +228,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$headers[] = 'Authorization: Bearer ' . $this->token;
}
if ($this->token === '') {
log_message('warning', '[EAtriaBridge] Authorization token missing for request: ' . $method . ' ' . $url);
}
$ch = curl_init();
$curlOptions = [
CURLOPT_URL => $url,
@ -252,6 +281,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$ok = ($httpCode >= 200 && $httpCode < 300) ? (int) ($decoded['ok'] ?? 1) : 0;
$this->dbLog($method, $path, $url, $body, $httpCode, (string) $raw, $ok === 1 ? 'success' : 'error', $ok === 0 ? (string) ($decoded['message'] ?? $decoded['error_text'] ?? '') : '');
if (($httpCode === 401 || $httpCode === 403) && ! isset($decoded['auth_hint'])) {
$decoded['auth_hint'] = 'Verify EATRIA_BRIDGE_TOKEN and ABDM_HFR_ID in hospital_setting. Current HFR=' . ($this->hfrId !== '' ? $this->hfrId : '(empty)');
}
return array_merge($decoded, ['ok' => $ok, 'http_code' => $httpCode]);
}

View File

@ -90,12 +90,12 @@ class AbdmAuditService
$insert = [];
// Preferred generic schema: audit_logs(event_id, patient_id, action, timestamp, user_id)
// Preferred generic schema: audit_logs(patient_id, abha_id, action, timestamp, details)
if (in_array('patient_id', $auditFields, true)) {
$insert['patient_id'] = $patientId > 0 ? $patientId : null;
$insert['patient_id'] = $patientId > 0 ? $patientId : 0;
}
if (in_array('action', $auditFields, true)) {
$insert['action'] = (string) ($data['action'] ?? 'unknown');
$insert['action'] = $this->mapAuditAction((string) ($data['action'] ?? ''));
}
if (in_array('timestamp', $auditFields, true)) {
$insert['timestamp'] = Time::now('Asia/Kolkata')->toDateTimeString();
@ -103,6 +103,29 @@ class AbdmAuditService
if (in_array('user_id', $auditFields, true)) {
$insert['user_id'] = $actorUserId > 0 ? $actorUserId : null;
}
if (in_array('abha_id', $auditFields, true)) {
$insert['abha_id'] = trim((string) ($data['abha_id'] ?? '')) !== ''
? trim((string) ($data['abha_id'] ?? ''))
: 'UNKNOWN';
}
if (in_array('consent_id', $auditFields, true)) {
$insert['consent_id'] = trim((string) ($data['consent_id'] ?? '')) ?: null;
}
if (in_array('transaction_id', $auditFields, true)) {
$insert['transaction_id'] = trim((string) ($data['transaction_id'] ?? '')) ?: null;
}
if (in_array('details', $auditFields, true)) {
$detailPayload = [
'raw_action' => (string) ($data['action'] ?? ''),
'entity_type' => (string) ($data['entity_type'] ?? ''),
'entity_id' => (string) ($data['entity_id'] ?? ''),
'outcome' => (string) ($data['outcome'] ?? 'success'),
'error_message' => trim((string) ($data['error_message'] ?? '')),
'request' => $data['request'] ?? null,
'response' => $data['response'] ?? null,
];
$insert['details'] = (string) json_encode($detailPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
// Extended ABDM schema fields (abdm_audit_logs)
if (in_array('actor_user_id', $auditFields, true)) {
@ -185,4 +208,18 @@ class AbdmAuditService
return [];
}
}
private function mapAuditAction(string $action): string
{
$action = strtolower(trim($action));
if (str_contains($action, 'discover')) {
return 'DISCOVERY';
}
if (str_contains($action, 'fetch')) {
return 'FETCH';
}
return 'SHARE';
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
$abha = $argv[1] ?? '91510165305101@sbx';
$patientId = (int)($argv[2] ?? 900001);
$envPath = __DIR__ . '/../.env';
$env = [];
if (is_file($envPath)) {
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$trim = trim((string) $line);
if ($trim === '' || str_starts_with($trim, '#')) {
continue;
}
$parts = explode('=', $trim, 2);
if (count($parts) === 2) {
$env[trim((string) $parts[0])] = trim((string) $parts[1]);
}
}
}
$dbHost = $env['database.default.hostname'] ?? 'localhost';
$dbUser = $env['database.default.username'] ?? 'root';
$dbPass = $env['database.default.password'] ?? '';
$dbName = $env['database.default.database'] ?? 'hms_data_ci4';
$dbPort = (int) ($env['database.default.port'] ?? 3306);
$mysqli = @new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort);
if ($mysqli->connect_errno) {
fwrite(STDERR, 'DB connection failed: ' . $mysqli->connect_error . PHP_EOL);
exit(1);
}
$check = $mysqli->query("SHOW TABLES LIKE 'patient_records'");
if (! $check || ! $check->fetch_row()) {
fwrite(STDERR, "Table patient_records not found in {$dbName}" . PHP_EOL);
exit(1);
}
$fhirBundle = [
'resourceType' => 'Bundle',
'type' => 'collection',
'entry' => [
[
'resource' => [
'resourceType' => 'Patient',
'id' => 'p-' . $patientId,
'identifier' => [
[
'system' => 'https://healthid.abdm.gov.in',
'value' => $abha,
],
],
'name' => [
[
'text' => 'M2 Strict Test Patient',
],
],
],
],
],
];
$fhirJson = json_encode($fhirBundle, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (! is_string($fhirJson)) {
fwrite(STDERR, 'Failed to encode sample FHIR bundle' . PHP_EOL);
exit(1);
}
$now = date('Y-m-d H:i:s');
$expiry = date('Y-m-d', strtotime('+3 years'));
$consentId = 'CONS-STRICT-' . date('YmdHis');
$deleteStmt = $mysqli->prepare('DELETE FROM patient_records WHERE abha_id = ? AND patient_id = ?');
$deleteStmt->bind_param('si', $abha, $patientId);
$deleteStmt->execute();
$deleteStmt->close();
$insertSql = 'INSERT INTO patient_records (patient_id, abha_id, consent_id, record_type, fhir_resource, created_at, updated_at, expiry_date, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)';
$insertStmt = $mysqli->prepare($insertSql);
if (! $insertStmt) {
fwrite(STDERR, 'Prepare failed: ' . $mysqli->error . PHP_EOL);
exit(1);
}
$recordType = 'OPD';
$status = 'ACTIVE';
$insertStmt->bind_param('issssssss', $patientId, $abha, $consentId, $recordType, $fhirJson, $now, $now, $expiry, $status);
$ok = $insertStmt->execute();
if (! $ok) {
fwrite(STDERR, 'Insert failed: ' . $insertStmt->error . PHP_EOL);
$insertStmt->close();
exit(1);
}
$recordId = $insertStmt->insert_id;
$insertStmt->close();
$mysqli->close();
echo 'Seeded patient_records row id=' . $recordId . ', abha=' . $abha . ', patient_id=' . $patientId . PHP_EOL;

View File

@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
/**
* Local M2 adapter endpoint tester for HMS.
*
* Usage (dry run, prints equivalent curl):
* php scripts/test_m2_adapter_endpoints.php --base=http://127.0.0.1:8080 --token=YOUR_TOKEN --secret=YOUR_HMAC_SECRET --dry-run
*
* Usage (real HTTP calls):
* php scripts/test_m2_adapter_endpoints.php --base=http://127.0.0.1:8080 --token=YOUR_TOKEN --secret=YOUR_HMAC_SECRET
*/
$options = getopt('', ['base::', 'token::', 'secret::', 'abha::', 'dry-run', 'auto-credentials']);
$baseUrl = rtrim((string) ($options['base'] ?? getenv('M2_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
$token = trim((string) ($options['token'] ?? getenv('M2_TOKEN') ?: ''));
$secret = trim((string) ($options['secret'] ?? getenv('M2_SECRET') ?: ''));
$abha = trim((string) ($options['abha'] ?? getenv('M2_ABHA') ?: '91510165305101@sbx'));
$dryRun = array_key_exists('dry-run', $options);
if (array_key_exists('auto-credentials', $options)) {
$dotEnv = loadDotEnv(__DIR__ . '/../.env');
$dbConfig = [
'host' => trim((string) ($dotEnv['database.default.hostname'] ?? getenv('database.default.hostname') ?: 'localhost')),
'user' => trim((string) ($dotEnv['database.default.username'] ?? getenv('database.default.username') ?: 'root')),
'pass' => trim((string) ($dotEnv['database.default.password'] ?? getenv('database.default.password') ?: '')),
'name' => trim((string) ($dotEnv['database.default.database'] ?? getenv('database.default.database') ?: 'hms_ci4_2026')),
'port' => (int) ($dotEnv['database.default.port'] ?? getenv('database.default.port') ?: 3306),
];
$mysqli = @new mysqli($dbConfig['host'], $dbConfig['user'], $dbConfig['pass'], $dbConfig['name'], $dbConfig['port']);
if (! $mysqli->connect_errno) {
if ($token === '') {
$token = resolveRuntimeSetting(
['GATEWAY_TO_HMS_TOKEN', 'ABDM_GATEWAY_TO_HMS_TOKEN', 'EKA_GATEWAY_TOKEN', 'EATRIA_BRIDGE_TOKEN', 'ABDM_BRIDGE_TOKEN'],
$dotEnv,
$mysqli
);
$token = preg_replace('/^Bearer\s+/i', '', $token) ?? $token;
}
if ($secret === '') {
$secret = resolveRuntimeSetting(['GATEWAY_TO_HMS_HMAC_SECRET', 'EKA_WEBHOOK_SECRET'], $dotEnv, $mysqli);
}
$mysqli->close();
}
}
if ($token === '') {
fwrite(STDERR, "Error: missing token. Provide --token or M2_TOKEN, or use --auto-credentials.\n");
exit(1);
}
$nowUtc = gmdate('Y-m-d\\TH:i:s\\Z');
$reqBase = 'REQ-' . gmdate('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
$txBase = 'TX-' . gmdate('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(2)));
$tests = [
[
'name' => 'Health Check',
'method' => 'GET',
'path' => '/api/v1/abdm/gateway/health',
'body' => null,
],
[
'name' => 'Discovery Care Contexts',
'method' => 'POST',
'path' => '/api/v1/abdm/gateway/discovery/care-contexts',
'body' => [
'request_id' => $reqBase . '-DISC',
'transaction_id' => $txBase . '-DISC',
'hospital_hfr_id' => 'IN0510000828',
'abha_id' => $abha,
'abha_address' => $abha,
'patient' => [
'name' => 'Demo Patient',
'gender' => 'M',
'year_of_birth' => 1985,
],
],
],
[
'name' => 'Health Information Fetch',
'method' => 'POST',
'path' => '/api/v1/abdm/gateway/health-information/fetch',
'body' => [
'request_id' => $reqBase . '-FETCH',
'transaction_id' => $txBase . '-FETCH',
'hospital_hfr_id' => 'IN0510000828',
'consent_id' => 'CONS-DEMO-001',
'abha_id' => $abha,
'abha_address' => $abha,
'date_range' => [
'from' => '2026-01-01T00:00:00Z',
'to' => $nowUtc,
],
'care_context_references' => [],
],
],
[
'name' => 'Consent Upsert',
'method' => 'POST',
'path' => '/api/v1/abdm/gateway/consent/upsert',
'body' => [
'request_id' => $reqBase . '-CONS',
'hospital_hfr_id' => 'IN0510000828',
'patient_id' => 1,
'consent_id' => 'CONS-DEMO-001',
'status' => 'GRANTED',
'abha_id' => $abha,
'abha_address' => $abha,
'purpose' => 'treatment',
'date_range' => [
'from' => '2026-01-01T00:00:00Z',
'to' => $nowUtc,
],
'hi_types' => ['OPConsultRecord', 'DiagnosticReportRecord'],
'raw_notification' => ['source' => 'local-test-runner'],
],
],
[
'name' => 'Link Status',
'method' => 'POST',
'path' => '/api/v1/abdm/gateway/link/status',
'body' => [
'request_id' => $reqBase . '-LINK',
'hospital_hfr_id' => 'IN0510000828',
'abha_id' => $abha,
'abha_address' => $abha,
'care_context_reference' => 'OPD-1-' . gmdate('Ymd'),
'status' => 'linked',
'linked_at' => $nowUtc,
'source' => 'hip_initiated',
],
],
];
foreach ($tests as $test) {
$method = (string) $test['method'];
$path = (string) $test['path'];
$body = $test['body'];
$json = $body === null ? '' : (string) json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$requestId = 'REQ-' . gmdate('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
$timestamp = gmdate('Y-m-d\\TH:i:s\\Z');
$signature = 'sha256=' . hash_hmac('sha256', $json, $secret !== '' ? $secret : '');
$headers = [
'Authorization: Bearer ' . $token,
'X-Request-Id: ' . $requestId,
'X-Timestamp: ' . $timestamp,
'X-Eka-Signature: ' . $signature,
'Accept: application/json',
];
if ($method === 'POST') {
$headers[] = 'Content-Type: application/json';
}
$url = $baseUrl . $path;
echo "\n============================================================\n";
echo 'TEST: ' . $test['name'] . "\n";
echo $method . ' ' . $url . "\n";
if ($dryRun) {
$escapedHeaders = '';
foreach ($headers as $h) {
$escapedHeaders .= ' -H ' . escapeshellarg($h);
}
if ($method === 'GET') {
echo 'curl -i -X GET ' . escapeshellarg($url) . $escapedHeaders . "\n";
} else {
echo 'curl -i -X POST ' . escapeshellarg($url) . $escapedHeaders . ' --data ' . escapeshellarg($json) . "\n";
}
echo "(dry-run only; request not sent)\n";
continue;
}
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_CUSTOMREQUEST => $method,
];
if ($method === 'POST') {
$opts[CURLOPT_POSTFIELDS] = $json;
}
curl_setopt_array($ch, $opts);
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$err = curl_error($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno !== 0) {
echo 'cURL error: [' . $errno . '] ' . $err . "\n";
continue;
}
$decoded = json_decode((string) $raw, true);
echo 'HTTP ' . $code . "\n";
if (is_array($decoded)) {
echo (string) json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
} else {
echo (string) $raw . "\n";
}
}
exit(0);
function loadDotEnv(string $envPath): array
{
if (! is_file($envPath)) {
return [];
}
$result = [];
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$trimmed = trim((string) $line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
continue;
}
$parts = explode('=', $trimmed, 2);
if (count($parts) !== 2) {
continue;
}
$key = trim((string) $parts[0]);
$val = trim((string) $parts[1]);
$result[$key] = $val;
}
return $result;
}
function resolveRuntimeSetting(array $candidates, array $dotEnv, mysqli $mysqli): string
{
foreach ($candidates as $name) {
$v = getenv($name);
if ($v !== false && trim((string) $v) !== '') {
return trim((string) $v);
}
if (! empty($dotEnv[$name])) {
return trim((string) $dotEnv[$name]);
}
$nameEsc = $mysqli->real_escape_string((string) $name);
$sql = "SELECT s_value FROM hospital_setting WHERE s_name = '" . $nameEsc . "' LIMIT 1";
$res = $mysqli->query($sql);
if (! $res) {
continue;
}
$row = $res->fetch_assoc();
$dbValue = trim((string) ($row['s_value'] ?? ''));
if ($dbValue !== '') {
return $dbValue;
}
}
return '';
}