feat(abdm): implement M2 gateway contract endpoints and live test tooling
This commit is contained in:
parent
985d476bc1
commit
fbaa985857
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
scripts/seed_m2_strict_test_data.php
Normal file
102
scripts/seed_m2_strict_test_data.php
Normal 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;
|
||||
272
scripts/test_m2_adapter_endpoints.php
Normal file
272
scripts/test_m2_adapter_endpoints.php
Normal 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 '';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user