Add ABDM M3 HIU workflows, routes, UI, and runbook docs

This commit is contained in:
DevSoftTech Server 2026-07-01 17:37:53 +05:30
parent 14a9d851ad
commit 3d40642116
12 changed files with 1578 additions and 1 deletions

View File

@ -0,0 +1,36 @@
<?php
namespace App\Commands;
use App\Libraries\Abdm\M3HiuWorkflowService;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class AbdmHiuRetry extends BaseCommand
{
protected $group = 'ABDM';
protected $name = 'abdm:hiu-retry';
protected $description = 'Retry failed transient ABDM M3 HIU workflow items.';
protected $usage = 'abdm:hiu-retry [--limit 20]';
protected $arguments = [];
protected $options = [
'--limit' => 'Maximum failed rows to retry in this run.',
];
public function run(array $params)
{
$limit = (int) (CLI::getOption('limit') ?? 20);
if ($limit <= 0) {
$limit = 20;
}
$service = new M3HiuWorkflowService();
$summary = $service->retryDue($limit);
CLI::write('ABDM M3 HIU Retry Summary', 'yellow');
CLI::write('Processed: ' . (int) ($summary['processed'] ?? 0));
CLI::write('Success: ' . (int) ($summary['success'] ?? 0), 'green');
CLI::write('Failed: ' . (int) ($summary['failed'] ?? 0), ((int) ($summary['failed'] ?? 0) > 0 ? 'red' : 'green'));
CLI::write('Skipped: ' . (int) ($summary['skipped'] ?? 0));
}
}

View File

@ -624,6 +624,18 @@ $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');
// ABDM M3 HIU (HMS -> Gateway)
$routes->get('AbdmHiu', 'AbdmHiu::index', ['filter' => $abdmPermFilter]);
$routes->get('AbdmHiu/patient_lookup', 'AbdmHiu::patientLookup', ['filter' => $abdmPermFilter]);
$routes->get('AbdmHiu/timeline', 'AbdmHiu::timeline', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/consent_request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/consent_request_status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/consent_request_fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/health_information_request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]);
$routes->post('api/v3/hiu/consent/request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]);
$routes->post('api/v3/hiu/consent/request/status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]);
$routes->post('api/v3/hiu/consent/request/fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]);
$routes->post('api/v3/hiu/health-information/request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]);
// 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');

265
app/Controllers/AbdmHiu.php Normal file
View File

@ -0,0 +1,265 @@
<?php
namespace App\Controllers;
use App\Libraries\Abdm\M3HiuWorkflowService;
class AbdmHiu extends BaseController
{
private M3HiuWorkflowService $service;
public function initController(
\CodeIgniter\HTTP\RequestInterface $request,
\CodeIgniter\HTTP\ResponseInterface $response,
\Psr\Log\LoggerInterface $logger
) {
parent::initController($request, $response, $logger);
$this->db = \Config\Database::connect();
$this->service = new M3HiuWorkflowService();
}
public function index()
{
return view('abdm/hiu_m3');
}
public function consentRequest()
{
return $this->handleOperation('consent_request', 'REQUESTED');
}
public function consentRequestStatus()
{
return $this->handleOperation('consent_status', 'STATUS_CHECKED');
}
public function consentRequestFetch()
{
return $this->handleOperation('consent_fetch', 'CONSENT_FETCHED');
}
public function healthInformationRequest()
{
return $this->handleOperation('hi_request', 'DATA_REQUESTED');
}
public function timeline()
{
$filters = [
'hfr_id' => trim((string) ($this->request->getGet('hfr_id') ?? '')),
'consent_id' => trim((string) ($this->request->getGet('consent_id') ?? '')),
'transaction_id' => trim((string) ($this->request->getGet('transaction_id') ?? '')),
'abha_address' => trim((string) ($this->request->getGet('abha_address') ?? '')),
'date_from' => trim((string) ($this->request->getGet('date_from') ?? '')),
'date_to' => trim((string) ($this->request->getGet('date_to') ?? '')),
];
$rows = $this->service->listTimeline($filters, 300);
return $this->response->setJSON([
'ok' => 1,
'count' => count($rows),
'items' => $rows,
]);
}
public function patientLookup()
{
if (! $this->db->tableExists('patient_master')) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'patient_master table not found',
]);
}
$q = trim((string) ($this->request->getGet('q') ?? ''));
if ($q !== '' && mb_strlen($q) < 2 && ! ctype_digit($q)) {
return $this->response->setJSON([
'ok' => 1,
'count' => 0,
'items' => [],
'note' => 'Type at least 2 characters to search.',
]);
}
$fields = $this->db->getFieldNames('patient_master') ?? [];
$idCol = $this->resolveExistingColumn($fields, ['id']);
$uhidCol = $this->resolveExistingColumn($fields, ['p_code', 'uhid', 'uhid_no', 'patient_code', 'patient_id']);
$nameCol = $this->resolveExistingColumn($fields, ['p_fname', 'patient_name', 'name']);
$mobileCol = $this->resolveExistingColumn($fields, ['p_mobile', 'mobile', 'phone', 'contact_no']);
$abhaNumberCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha_number', 'abha']);
$abhaAddressCol = $this->resolveExistingColumn($fields, ['abha_address', 'abha_addr']);
if ($idCol === null) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'patient_master.id column not found',
]);
}
$builder = $this->db->table('patient_master p')
->select('p.' . $idCol . ' AS patient_id', false)
->orderBy('p.' . $idCol, 'DESC')
->limit(30);
if ($uhidCol !== null) {
$builder->select('p.' . $uhidCol . ' AS patient_code', false);
} else {
$builder->select("'' AS patient_code", false);
}
if ($nameCol !== null) {
$builder->select('p.' . $nameCol . ' AS patient_name', false);
} else {
$builder->select("'' AS patient_name", false);
}
if ($mobileCol !== null) {
$builder->select('p.' . $mobileCol . ' AS mobile', false);
} else {
$builder->select("'' AS mobile", false);
}
if ($abhaNumberCol !== null) {
$builder->select('p.' . $abhaNumberCol . ' AS abha_number', false);
} else {
$builder->select("'' AS abha_number", false);
}
if ($abhaAddressCol !== null) {
$builder->select('p.' . $abhaAddressCol . ' AS abha_address', false);
} else {
$builder->select("'' AS abha_address", false);
}
if (in_array('log', $fields, true)) {
$builder->select('p.log AS patient_log', false);
} else {
$builder->select("'' AS patient_log", false);
}
if ($q !== '') {
$builder->groupStart();
if ($nameCol !== null) {
$builder->like('p.' . $nameCol, $q);
}
if ($uhidCol !== null) {
$builder->orLike('p.' . $uhidCol, $q);
}
if ($mobileCol !== null) {
$builder->orLike('p.' . $mobileCol, $q);
}
if ($abhaNumberCol !== null) {
$builder->orLike('p.' . $abhaNumberCol, $q);
}
if ($abhaAddressCol !== null) {
$builder->orLike('p.' . $abhaAddressCol, $q);
}
if (ctype_digit($q)) {
$builder->orWhere('p.' . $idCol, (int) $q);
}
$builder->groupEnd();
}
$rows = $builder->get()->getResultArray();
$items = [];
foreach ($rows as $row) {
$abhaNumber = trim((string) ($row['abha_number'] ?? ''));
$abhaAddress = trim((string) ($row['abha_address'] ?? ''));
if ($abhaAddress === '') {
$abhaAddress = $this->extractAbhaAddressFromLog((string) ($row['patient_log'] ?? ''));
}
$latest = $this->findLatestConsentByAbhaAddress($abhaAddress);
$items[] = [
'patient_id' => (int) ($row['patient_id'] ?? 0),
'patient_code' => trim((string) ($row['patient_code'] ?? '')),
'patient_name' => trim((string) ($row['patient_name'] ?? '')),
'mobile' => trim((string) ($row['mobile'] ?? '')),
'abha_number' => $abhaNumber,
'abha_address' => $abhaAddress,
'has_abha_number' => preg_match('/^\d{14}$/', $abhaNumber) === 1 ? 1 : 0,
'has_abha_address' => $abhaAddress !== '' ? 1 : 0,
'latest_consent' => $latest,
];
}
return $this->response->setJSON([
'ok' => 1,
'count' => count($items),
'items' => $items,
]);
}
private function handleOperation(string $operation, string $successState)
{
$payload = $this->request->getJSON(true);
if (! is_array($payload)) {
$payload = $this->request->getPost() ?? [];
}
$result = $this->service->runOperation($operation, $payload);
$ok = (int) ($result['ok'] ?? 0) === 1;
$httpCode = (int) ($result['http_code'] ?? ($ok ? 200 : 400));
if ($httpCode < 100 || $httpCode > 599) {
$httpCode = $ok ? 200 : 400;
}
$requestId = (string) ($result['request_id'] ?? $result['requestId'] ?? $payload['request_id'] ?? $payload['requestId'] ?? '');
if ($ok) {
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 1,
'request_id' => $requestId,
'http_code' => $httpCode,
'data' => $result,
'workflow_state' => $successState,
'duplicate' => (int) ($result['duplicate'] ?? 0),
]);
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'error' => (string) ($result['error_text'] ?? $result['message'] ?? 'HIU operation failed'),
'request_id' => $requestId,
'http_code' => $httpCode,
'retryable' => (int) ($result['retryable'] ?? 0),
'workflow_state' => 'FAILED',
'data' => $result,
]);
}
private function findLatestConsentByAbhaAddress(string $abhaAddress): ?array
{
if ($abhaAddress === '' || ! $this->db->tableExists('abdm_hiu_workflows')) {
return null;
}
$row = $this->db->table('abdm_hiu_workflows')
->select('id, operation, workflow_state, status, consent_id, request_id, transaction_id, created_at, updated_at')
->where('abha_address', $abhaAddress)
->whereIn('operation', ['consent_request', 'consent_status', 'consent_fetch'])
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
return ! empty($row) ? $row : null;
}
private function resolveExistingColumn(array $fields, array $candidates): ?string
{
foreach ($candidates as $candidate) {
if (in_array($candidate, $fields, true)) {
return $candidate;
}
}
return null;
}
private function extractAbhaAddressFromLog(string $logText): string
{
if ($logText === '') {
return '';
}
if (preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', $logText, $m) === 1) {
return trim((string) ($m[1] ?? ''));
}
return '';
}
}

View File

@ -2063,7 +2063,7 @@ class Patient extends BaseController
}
}
if (in_array('abha_address', $fields, true)) {
if (in_array('abha_address', $fields, true) && $abhaAddress !== '') {
$data['abha_address'] = $abhaAddress;
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateAbdmM3HiuWorkflows extends Migration
{
public function up(): void
{
if ($this->db->tableExists('abdm_hiu_workflows')) {
return;
}
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'hospital_id' => [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => true,
],
'hfr_id' => [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => false,
],
'abha_address' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'consent_id' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => true,
],
'request_id' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => true,
],
'transaction_id' => [
'type' => 'VARCHAR',
'constraint' => 160,
'null' => true,
],
'operation' => [
'type' => 'VARCHAR',
'constraint' => 40,
'null' => false,
'comment' => 'consent_request|consent_status|consent_fetch|hi_request',
],
'workflow_state' => [
'type' => 'VARCHAR',
'constraint' => 40,
'default' => 'CREATED',
],
'status' => [
'type' => 'VARCHAR',
'constraint' => 20,
'default' => 'created',
],
'http_code' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
],
'is_retryable' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
],
'retry_count' => [
'type' => 'INT',
'constraint' => 11,
'default' => 0,
],
'next_retry_at' => [
'type' => 'DATETIME',
'null' => true,
],
'last_error' => [
'type' => 'TEXT',
'null' => true,
],
'request_json' => [
'type' => 'LONGTEXT',
'null' => true,
],
'response_json' => [
'type' => 'LONGTEXT',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'completed_at' => [
'type' => 'DATETIME',
'null' => true,
],
'expired_at' => [
'type' => 'DATETIME',
'null' => true,
],
'revoked_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['request_id', 'operation'], 'uniq_abdm_hiu_req_op');
$this->forge->addUniqueKey(['transaction_id', 'operation'], 'uniq_abdm_hiu_txn_op');
$this->forge->addKey(['hfr_id', 'created_at'], false, false, 'idx_abdm_hiu_hfr_created');
$this->forge->addKey(['consent_id', 'created_at'], false, false, 'idx_abdm_hiu_consent_created');
$this->forge->addKey(['status', 'next_retry_at'], false, false, 'idx_abdm_hiu_retry');
$this->forge->addKey(['workflow_state', 'created_at'], false, false, 'idx_abdm_hiu_state_created');
$this->forge->createTable('abdm_hiu_workflows', true);
}
public function down(): void
{
$this->forge->dropTable('abdm_hiu_workflows', true);
}
}

View File

@ -0,0 +1,214 @@
<?php
namespace App\Libraries\Abdm;
class M3HiuGatewayClient
{
private \CodeIgniter\Database\BaseConnection $db;
private string $baseUrl;
private int $timeoutSec = 45;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->baseUrl = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/');
}
public function consentRequest(array $payload): array
{
return $this->call('/v3/hiu/consent/request', $payload, 'consent.request');
}
public function consentRequestStatus(array $payload): array
{
return $this->call('/v3/hiu/consent/request/status', $payload, 'consent.request.status');
}
public function consentRequestFetch(array $payload): array
{
return $this->call('/v3/hiu/consent/request/fetch', $payload, 'consent.request.fetch');
}
public function healthInformationRequest(array $payload): array
{
return $this->call('/v3/hiu/health-information/request', $payload, 'health-information.request');
}
private function call(string $path, array $payload, string $eventKey): array
{
$settings = $this->resolveRuntimeSettings();
if (($settings['ok'] ?? 0) !== 1) {
return $settings;
}
$token = (string) ($settings['token'] ?? '');
$hfrId = (string) ($settings['hfr_id'] ?? '');
$hospitalId = (string) ($settings['hospital_id'] ?? '');
$payloadHfrId = trim((string) ($payload['hfr_id'] ?? ''));
if ($payloadHfrId !== '' && strcasecmp($payloadHfrId, $hfrId) !== 0) {
return [
'ok' => 0,
'http_code' => 400,
'error_text' => 'hfr_id mismatch: payload hfr_id does not match hospital_setting.ABDM_HFR_ID',
'retryable' => 0,
];
}
$payload['hfr_id'] = $hfrId;
$url = $this->baseUrl . $path;
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . $token,
];
if ($hospitalId !== '') {
$headers[] = 'X-Hospital-Id: ' . $hospitalId;
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSec,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$raw = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
$decoded = json_decode((string) $raw, true);
$isJson = is_array($decoded);
$ok = $isJson ? (int) ($decoded['ok'] ?? ($httpCode >= 200 && $httpCode < 300 ? 1 : 0)) : (int) ($httpCode >= 200 && $httpCode < 300);
$errorText = '';
if ($curlErr !== '') {
$errorText = $curlErr;
} elseif ($ok !== 1) {
$errorText = $isJson
? (string) ($decoded['message'] ?? $decoded['error_text'] ?? $decoded['error'] ?? ('HTTP ' . $httpCode))
: ('HTTP ' . $httpCode . ' non-JSON response');
}
$retryable = 0;
if ($curlErr !== '' || $httpCode >= 500 || $httpCode === 0 || str_contains(strtolower($errorText), 'timed out')) {
$retryable = 1;
}
$response = $isJson ? $decoded : ['raw' => (string) $raw];
$response['ok'] = $ok;
$response['http_code'] = $httpCode;
$response['error_text'] = $errorText;
$response['retryable'] = $retryable;
if (! isset($response['request_id'])) {
$response['request_id'] = (string) ($response['requestId'] ?? $payload['requestId'] ?? $payload['request_id'] ?? '');
}
if (! isset($response['transaction_id'])) {
$response['transaction_id'] = (string) ($response['transactionId'] ?? $payload['transactionId'] ?? $payload['transaction_id'] ?? '');
}
if (! isset($response['consent_id'])) {
$response['consent_id'] = (string) ($response['consentId'] ?? $response['consentRequestId'] ?? $payload['consentId'] ?? $payload['consentRequestId'] ?? $payload['consent_id'] ?? '');
}
$this->writeApiLog($eventKey, $path, $payload, $response, $httpCode, $ok, $errorText, $hospitalId);
return $response;
}
private function resolveRuntimeSettings(): array
{
if (! $this->db->tableExists('hospital_setting')) {
return ['ok' => 0, 'http_code' => 500, 'error_text' => 'hospital_setting table not found', 'retryable' => 0];
}
$rows = $this->db->table('hospital_setting')
->select('s_name, s_value')
->whereIn('s_name', ['EATRIA_BRIDGE_TOKEN', 'ABDM_HFR_ID', 'ABDM_HOSPITAL_ID'])
->get()
->getResultArray();
$kv = [];
foreach ($rows as $row) {
$key = trim((string) ($row['s_name'] ?? ''));
if ($key === '') {
continue;
}
$kv[$key] = trim((string) ($row['s_value'] ?? ''));
}
$token = (string) ($kv['EATRIA_BRIDGE_TOKEN'] ?? '');
$hfrId = (string) ($kv['ABDM_HFR_ID'] ?? '');
$hospitalId = (string) ($kv['ABDM_HOSPITAL_ID'] ?? '');
if ($token === '') {
return [
'ok' => 0,
'http_code' => 401,
'error_text' => 'Missing token: set hospital_setting.EATRIA_BRIDGE_TOKEN',
'retryable' => 0,
];
}
if ($hfrId === '') {
return [
'ok' => 0,
'http_code' => 400,
'error_text' => 'Missing hfr_id: set hospital_setting.ABDM_HFR_ID',
'retryable' => 0,
];
}
return [
'ok' => 1,
'token' => $token,
'hfr_id' => $hfrId,
'hospital_id' => $hospitalId,
];
}
private function writeApiLog(
string $eventKey,
string $path,
array $request,
array $response,
int $httpCode,
int $ok,
string $errorText,
string $hospitalId
): void {
if (! $this->db->tableExists('abdm_api_logs')) {
return;
}
$status = $ok === 1 ? 'success' : 'error';
$entityId = trim((string) (
$request['consent_id']
?? $request['consentId']
?? $request['consentRequestId']
?? $request['request_id']
?? $request['requestId']
?? $request['transaction_id']
?? $request['transactionId']
?? ''
));
$this->db->table('abdm_api_logs')->insert([
'channel' => 'eatria_bridge',
'event_type' => 'abdm.m3.hiu.' . $eventKey,
'endpoint' => $this->baseUrl . $path,
'http_method' => 'POST',
'entity_type' => 'abdm_hiu',
'entity_id' => $entityId,
'request_json' => (string) json_encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'response_code' => $httpCode,
'response_json' => (string) json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'status' => $status,
'error_message' => $errorText !== '' ? mb_substr($errorText, 0, 1000) : null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
}

View File

@ -0,0 +1,332 @@
<?php
namespace App\Libraries\Abdm;
use CodeIgniter\I18n\Time;
use App\Libraries\Abdm\M3HiuGatewayClient;
class M3HiuWorkflowService
{
private \CodeIgniter\Database\BaseConnection $db;
private M3HiuGatewayClient $client;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->client = new M3HiuGatewayClient();
}
public function runOperation(string $operation, array $payload): array
{
$hasWorkflowTable = $this->db->tableExists('abdm_hiu_workflows');
$normalized = $this->normalizePayload($payload);
if ($hasWorkflowTable && $operation !== 'consent_status') {
$duplicate = $this->findDuplicate($operation, $normalized);
if ($duplicate !== null) {
$dupResponse = json_decode((string) ($duplicate['response_json'] ?? ''), true);
if (is_array($dupResponse) && ! empty($dupResponse)) {
$dupResponse['duplicate'] = 1;
$dupResponse['workflow_state'] = (string) ($duplicate['workflow_state'] ?? '');
return $dupResponse;
}
}
}
$result = $this->dispatch($operation, $normalized);
$httpCode = (int) ($result['http_code'] ?? 0);
$ok = (int) ($result['ok'] ?? 0) === 1;
$state = $this->resolveState($operation, $result);
$status = $ok ? 'success' : 'failed';
$retryable = (int) ($result['retryable'] ?? 0) === 1;
if ($hasWorkflowTable) {
$this->upsertWorkflow($operation, $normalized, $result, $state, $status, $httpCode, $retryable);
$result['workflow_persisted'] = 1;
} else {
$result['workflow_persisted'] = 0;
$result['workflow_warning'] = 'abdm_hiu_workflows table not found; operation executed without timeline persistence';
}
return $result;
}
public function retryDue(int $limit = 20): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return ['processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0];
}
$rows = $this->db->table('abdm_hiu_workflows')
->select('*')
->where('status', 'failed')
->where('is_retryable', 1)
->where('retry_count <', 3)
->groupStart()
->where('next_retry_at IS NULL', null, false)
->orWhere('next_retry_at <=', Time::now('Asia/Kolkata')->toDateTimeString())
->groupEnd()
->orderBy('id', 'ASC')
->get($limit)
->getResultArray();
$summary = ['processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0];
foreach ($rows as $row) {
$summary['processed']++;
$operation = trim((string) ($row['operation'] ?? ''));
$payload = json_decode((string) ($row['request_json'] ?? ''), true);
if (! is_array($payload) || $operation === '') {
$summary['skipped']++;
continue;
}
$result = $this->dispatch($operation, $payload);
$ok = (int) ($result['ok'] ?? 0) === 1;
$state = $this->resolveState($operation, $result);
$status = $ok ? 'success' : 'failed';
$retryable = (int) ($result['retryable'] ?? 0) === 1;
$this->upsertWorkflow($operation, $payload, $result, $state, $status, (int) ($result['http_code'] ?? 0), $retryable, (int) ($row['id'] ?? 0));
if ($ok) {
$summary['success']++;
} else {
$summary['failed']++;
}
}
return $summary;
}
public function listTimeline(array $filters, int $limit = 200): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return [];
}
$builder = $this->db->table('abdm_hiu_workflows')->select('*')->orderBy('id', 'DESC');
$hfrId = trim((string) ($filters['hfr_id'] ?? ''));
if ($hfrId !== '') {
$builder->where('hfr_id', $hfrId);
}
$consentId = trim((string) ($filters['consent_id'] ?? ''));
if ($consentId !== '') {
$builder->where('consent_id', $consentId);
}
$transactionId = trim((string) ($filters['transaction_id'] ?? ''));
if ($transactionId !== '') {
$builder->where('transaction_id', $transactionId);
}
$abhaAddress = trim((string) ($filters['abha_address'] ?? ''));
if ($abhaAddress !== '') {
$builder->where('abha_address', $abhaAddress);
}
$from = trim((string) ($filters['date_from'] ?? ''));
if ($from !== '') {
$builder->where('created_at >=', $from . ' 00:00:00');
}
$to = trim((string) ($filters['date_to'] ?? ''));
if ($to !== '') {
$builder->where('created_at <=', $to . ' 23:59:59');
}
return $builder->get($limit)->getResultArray();
}
private function dispatch(string $operation, array $payload): array
{
return match ($operation) {
'consent_request' => $this->client->consentRequest($payload),
'consent_status' => $this->client->consentRequestStatus($payload),
'consent_fetch' => $this->client->consentRequestFetch($payload),
'hi_request' => $this->client->healthInformationRequest($payload),
default => ['ok' => 0, 'http_code' => 400, 'error_text' => 'Unsupported operation: ' . $operation, 'retryable' => 0],
};
}
private function normalizePayload(array $payload): array
{
$normalized = $payload;
// Accept both legacy snake_case and guide-defined camelCase keys.
if (! isset($normalized['request_id']) && isset($normalized['requestId'])) {
$normalized['request_id'] = (string) $normalized['requestId'];
}
if (! isset($normalized['transaction_id']) && isset($normalized['transactionId'])) {
$normalized['transaction_id'] = (string) $normalized['transactionId'];
}
if (! isset($normalized['consent_id']) && isset($normalized['consentId'])) {
$normalized['consent_id'] = (string) $normalized['consentId'];
}
if (! isset($normalized['consent_id']) && isset($normalized['consentRequestId'])) {
$normalized['consent_id'] = (string) $normalized['consentRequestId'];
}
if (isset($normalized['abha_id']) && ! isset($normalized['abha_address'])) {
$normalized['abha_address'] = (string) $normalized['abha_id'];
}
foreach (['request_id', 'transaction_id', 'consent_id', 'abha_address', 'hfr_id'] as $k) {
if (! isset($normalized[$k])) {
$normalized[$k] = '';
}
$normalized[$k] = trim((string) $normalized[$k]);
}
if ($normalized['request_id'] === '') {
$normalized['request_id'] = 'REQ-HIU-' . date('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
}
if (! isset($normalized['requestId']) || trim((string) $normalized['requestId']) === '') {
$normalized['requestId'] = $normalized['request_id'];
}
if (! isset($normalized['timestamp']) || trim((string) $normalized['timestamp']) === '') {
$normalized['timestamp'] = gmdate('Y-m-d\TH:i:s.000\Z');
}
if ($normalized['transaction_id'] !== '' && (! isset($normalized['transactionId']) || trim((string) $normalized['transactionId']) === '')) {
$normalized['transactionId'] = $normalized['transaction_id'];
}
if ($normalized['consent_id'] !== '' && (! isset($normalized['consentId']) || trim((string) $normalized['consentId']) === '')) {
$normalized['consentId'] = $normalized['consent_id'];
}
return $normalized;
}
private function resolveState(string $operation, array $result): string
{
$ok = (int) ($result['ok'] ?? 0) === 1;
if (! $ok) {
return 'FAILED';
}
$statusStr = strtolower(trim((string) ($result['status'] ?? $result['consent_status'] ?? '')));
if (in_array($statusStr, ['revoked', 'denied'], true)) {
return 'REVOKED';
}
if (in_array($statusStr, ['expired'], true)) {
return 'EXPIRED';
}
return match ($operation) {
'consent_request' => 'REQUESTED',
'consent_status' => 'STATUS_CHECKED',
'consent_fetch' => 'CONSENT_FETCHED',
'hi_request' => ($statusStr === 'completed' ? 'COMPLETED' : 'DATA_REQUESTED'),
default => 'COMPLETED',
};
}
private function findDuplicate(string $operation, array $payload): ?array
{
$requestId = trim((string) ($payload['request_id'] ?? ''));
$transactionId = trim((string) ($payload['transaction_id'] ?? ''));
$builder = $this->db->table('abdm_hiu_workflows')->select('*')->where('operation', $operation);
if ($requestId !== '') {
$row = $builder->where('request_id', $requestId)->orderBy('id', 'DESC')->get(1)->getRowArray();
if (! empty($row)) {
return $row;
}
}
if ($transactionId !== '') {
$row = $this->db->table('abdm_hiu_workflows')
->select('*')
->where('operation', $operation)
->where('transaction_id', $transactionId)
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
if (! empty($row)) {
return $row;
}
}
return null;
}
private function upsertWorkflow(
string $operation,
array $payload,
array $result,
string $state,
string $status,
int $httpCode,
bool $retryable,
int $forceId = 0
): void {
$now = Time::now('Asia/Kolkata')->toDateTimeString();
$requestId = trim((string) ($payload['request_id'] ?? $result['request_id'] ?? ''));
$transactionId = trim((string) ($payload['transaction_id'] ?? $result['transaction_id'] ?? ''));
$consentId = trim((string) ($payload['consent_id'] ?? $result['consent_id'] ?? ''));
$abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abha_id'] ?? $result['abha_address'] ?? ''));
$hfrId = trim((string) ($payload['hfr_id'] ?? $result['hfr_id'] ?? ''));
$hospitalId = trim((string) ($payload['hospital_id'] ?? $result['hospital_id'] ?? ''));
$errorText = trim((string) ($result['error_text'] ?? $result['message'] ?? ''));
$retryCount = 0;
$id = $forceId;
if ($id <= 0) {
$existing = $this->findDuplicate($operation, ['request_id' => $requestId, 'transaction_id' => $transactionId]);
if (! empty($existing)) {
$id = (int) ($existing['id'] ?? 0);
$retryCount = (int) ($existing['retry_count'] ?? 0);
}
} else {
$existing = $this->db->table('abdm_hiu_workflows')->where('id', $id)->get(1)->getRowArray();
$retryCount = (int) ($existing['retry_count'] ?? 0);
}
if ($status === 'failed' && $retryable) {
$retryCount++;
}
$nextRetryAt = null;
if ($status === 'failed' && $retryable && $retryCount < 4) {
$backoffMinutes = min(30, 1 * (2 ** max(0, $retryCount - 1)));
$nextRetryAt = date('Y-m-d H:i:s', strtotime('+' . $backoffMinutes . ' minutes'));
}
$row = [
'hospital_id' => $hospitalId !== '' ? $hospitalId : null,
'hfr_id' => $hfrId !== '' ? $hfrId : 'UNKNOWN',
'abha_address' => $abhaAddress !== '' ? $abhaAddress : null,
'consent_id' => $consentId !== '' ? $consentId : null,
'request_id' => $requestId !== '' ? $requestId : null,
'transaction_id' => $transactionId !== '' ? $transactionId : null,
'operation' => $operation,
'workflow_state' => $state,
'status' => $status,
'http_code' => $httpCode > 0 ? $httpCode : null,
'is_retryable' => $retryable ? 1 : 0,
'retry_count' => $retryCount,
'next_retry_at' => $nextRetryAt,
'last_error' => $errorText !== '' ? mb_substr($errorText, 0, 2000) : null,
'request_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'response_json' => (string) json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => $now,
];
if (in_array($state, ['COMPLETED', 'CONSENT_FETCHED'], true)) {
$row['completed_at'] = $now;
}
if ($state === 'EXPIRED') {
$row['expired_at'] = $now;
}
if ($state === 'REVOKED') {
$row['revoked_at'] = $now;
}
if ($id > 0) {
$this->db->table('abdm_hiu_workflows')->where('id', $id)->update($row);
return;
}
$row['created_at'] = $now;
$this->db->table('abdm_hiu_workflows')->insert($row);
}
}

400
app/Views/abdm/hiu_m3.php Normal file
View File

@ -0,0 +1,400 @@
<div class="container-fluid py-3">
<div class="row g-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">ABDM M3 HIU Console</h5>
<button class="btn btn-sm btn-outline-primary" id="btnRefreshTimeline">Refresh Timeline</button>
</div>
<div class="card-body">
<div class="alert alert-info mb-3">
Search patient first. Consent Request is enabled only when ABHA Number and ABHA Address are both present.
</div>
<div class="card border-0 bg-light mb-3">
<div class="card-body py-3">
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label mb-1">Search Patient</label>
<input id="patient_search" class="form-control" placeholder="Name / UHID / Mobile / ABHA">
</div>
<div class="col-md-2">
<button id="btnPatientSearch" class="btn btn-outline-secondary w-100">Search</button>
</div>
<div class="col-md-4">
<div id="patient_status" class="small text-muted">No patient selected.</div>
</div>
</div>
<div class="table-responsive mt-2">
<table class="table table-sm table-hover mb-0" id="patient_table">
<thead>
<tr>
<th>Action</th>
<th>Patient</th>
<th>UHID</th>
<th>ABHA Number</th>
<th>ABHA Address</th>
<th>Last Consent State</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-md-3"><input id="selected_patient" class="form-control" placeholder="Selected Patient" readonly></div>
<div class="col-md-2"><input id="request_id" class="form-control" placeholder="requestId"></div>
<div class="col-md-2"><input id="transaction_id" class="form-control" placeholder="transactionId"></div>
<div class="col-md-2"><input id="consent_id" class="form-control" placeholder="consentId / consentRequestId"></div>
<div class="col-md-3"><input id="abha_address" class="form-control" placeholder="abha_address" readonly></div>
</div>
<div class="small mb-3" id="readiness_box"></div>
<div class="mb-3">
<textarea id="payload_json" class="form-control" rows="8" placeholder='{"consent":{"patient":{"id":"someone@abdm"},"hiu":{"id":"YOUR_HIU_ID"},"hiTypes":["OPConsultation"]}}'></textarea>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary" id="btnConsentRequest" data-op="consent_request" disabled>Create Consent Request</button>
<button class="btn btn-outline-primary" id="btnConsentStatus" data-op="consent_status" disabled>Check Consent Status</button>
<button class="btn btn-outline-primary" id="btnConsentFetch" data-op="consent_fetch" disabled>Fetch Consent Artifact</button>
<button class="btn btn-success" id="btnHiRequest" data-op="hi_request" disabled>Request Health Information</button>
</div>
<hr>
<pre id="result_box" class="bg-dark text-light p-3 rounded" style="min-height:120px">{}</pre>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header">Timeline / Logs</div>
<div class="card-body">
<div class="row g-2 mb-2">
<div class="col-md-2"><input id="f_hfr_id" class="form-control" placeholder="Filter hfr_id"></div>
<div class="col-md-2"><input id="f_consent_id" class="form-control" placeholder="Filter consent_id"></div>
<div class="col-md-2"><input id="f_transaction_id" class="form-control" placeholder="Filter transaction_id"></div>
<div class="col-md-2"><input id="f_abha_address" class="form-control" placeholder="Filter abha_address"></div>
<div class="col-md-2"><input id="f_date_from" type="date" class="form-control"></div>
<div class="col-md-2"><input id="f_date_to" type="date" class="form-control"></div>
<div class="col-md-12"><button id="btnApplyFilter" class="btn btn-outline-secondary w-100">Apply Filters</button></div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle" id="timeline_table">
<thead>
<tr>
<th>ID</th>
<th>Time</th>
<th>Operation</th>
<th>State</th>
<th>Status</th>
<th>HFR</th>
<th>ABHA Address</th>
<th>Consent</th>
<th>Txn</th>
<th>Request</th>
<th>Error</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
var csrfName = '<?= csrf_token() ?>';
var csrfHash = '<?= csrf_hash() ?>';
var selectedPatient = null;
var consentPollTimer = null;
var opRoute = {
consent_request: '<?= base_url('AbdmHiu/consent_request') ?>',
consent_status: '<?= base_url('AbdmHiu/consent_request_status') ?>',
consent_fetch: '<?= base_url('AbdmHiu/consent_request_fetch') ?>',
hi_request: '<?= base_url('AbdmHiu/health_information_request') ?>'
};
var patientLookupUrl = '<?= base_url('AbdmHiu/patient_lookup') ?>';
function escHtml(v) {
return (v || '').toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function genReq(prefix) {
var p = prefix || 'REQ-HIU';
return p + '-' + Date.now();
}
function updateReadinessBox(consentStateText) {
var box = document.getElementById('readiness_box');
if (!selectedPatient) {
box.innerHTML = '<span class="text-muted">Select a patient to continue.</span>';
return;
}
var hasNum = selectedPatient.has_abha_number === 1;
var hasAddr = selectedPatient.has_abha_address === 1;
var state = (consentStateText || '').trim();
box.innerHTML = '' +
'<span class="badge ' + (hasNum ? 'bg-success' : 'bg-secondary') + ' me-1">ABHA Number: ' + (hasNum ? 'Ready' : 'Missing') + '</span>' +
'<span class="badge ' + (hasAddr ? 'bg-success' : 'bg-secondary') + ' me-1">ABHA Address: ' + (hasAddr ? 'Ready' : 'Missing') + '</span>' +
'<span class="badge bg-info text-dark">Consent: ' + escHtml(state || 'Unknown') + '</span>';
}
function isConsentApproved(statusText) {
var s = (statusText || '').toString().trim().toLowerCase();
return ['approved', 'granted', 'active', 'linked', 'completed'].indexOf(s) !== -1;
}
function syncButtons(consentStateText) {
var hasPatient = !!selectedPatient;
var hasNum = hasPatient && selectedPatient.has_abha_number === 1;
var hasAddr = hasPatient && selectedPatient.has_abha_address === 1;
var hasConsentRef = (document.getElementById('consent_id').value.trim() !== '' || document.getElementById('request_id').value.trim() !== '');
document.getElementById('btnConsentRequest').disabled = !(hasNum && hasAddr);
document.getElementById('btnConsentStatus').disabled = !hasConsentRef;
document.getElementById('btnConsentFetch').disabled = !hasConsentRef;
document.getElementById('btnHiRequest').disabled = !(hasConsentRef && isConsentApproved(consentStateText));
updateReadinessBox(consentStateText);
}
function setSelectedPatient(p) {
selectedPatient = p;
document.getElementById('selected_patient').value = (p.patient_name || '') + (p.patient_code ? ' (' + p.patient_code + ')' : '');
document.getElementById('abha_address').value = p.abha_address || '';
document.getElementById('f_abha_address').value = p.abha_address || '';
document.getElementById('request_id').value = genReq('REQ-CONSENT');
document.getElementById('transaction_id').value = genReq('TXN-HIU');
var latest = p.latest_consent || {};
var consentRef = (latest.consent_id || latest.request_id || '').toString();
if (consentRef) {
document.getElementById('consent_id').value = consentRef;
document.getElementById('f_consent_id').value = consentRef;
}
var consentState = (latest.workflow_state || latest.status || 'Unknown').toString();
document.getElementById('patient_status').textContent = 'Selected: ' + (p.patient_name || '-') + ' | Consent: ' + consentState;
syncButtons(consentState);
fetchTimeline();
}
function renderPatientRows(items) {
var tbody = document.querySelector('#patient_table tbody');
tbody.innerHTML = '';
items.forEach(function (p, idx) {
var tr = document.createElement('tr');
tr.innerHTML = '' +
'<td><button class="btn btn-sm btn-outline-primary" data-select-idx="' + idx + '">Select</button></td>' +
'<td>' + escHtml(p.patient_name || '') + '</td>' +
'<td>' + escHtml(p.patient_code || '') + '</td>' +
'<td>' + escHtml(p.abha_number || '') + '</td>' +
'<td>' + escHtml(p.abha_address || '') + '</td>' +
'<td>' + escHtml(((p.latest_consent || {}).workflow_state || (p.latest_consent || {}).status || '-')) + '</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('button[data-select-idx]').forEach(function (btn) {
btn.addEventListener('click', function () {
var idx = parseInt(btn.getAttribute('data-select-idx'), 10);
if (!Number.isNaN(idx) && items[idx]) {
setSelectedPatient(items[idx]);
}
});
});
}
function searchPatients() {
var q = document.getElementById('patient_search').value.trim();
var url = patientLookupUrl + '?q=' + encodeURIComponent(q);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (res) {
if ((res.ok || 0) !== 1) {
document.getElementById('patient_status').textContent = res.error || 'Patient search failed';
return;
}
renderPatientRows(res.items || []);
document.getElementById('patient_status').textContent = (res.count || 0) + ' patient(s) found';
})
.catch(function (e) {
document.getElementById('patient_status').textContent = e.message || 'Patient search failed';
});
}
function readPayload(op) {
var custom = {};
try {
var raw = document.getElementById('payload_json').value.trim();
custom = raw ? JSON.parse(raw) : {};
} catch (e) {
throw new Error('Invalid payload JSON');
}
var payload = Object.assign({}, custom, {
requestId: document.getElementById('request_id').value.trim(),
transactionId: document.getElementById('transaction_id').value.trim(),
abha_address: document.getElementById('abha_address').value.trim(),
patient_id: selectedPatient ? selectedPatient.patient_id : null
});
var consentVal = document.getElementById('consent_id').value.trim();
if (consentVal !== '') {
payload.consentId = consentVal;
payload.consentRequestId = consentVal;
}
if (!payload.timestamp) {
payload.timestamp = new Date().toISOString();
}
payload[csrfName] = csrfHash;
return payload;
}
function setResult(obj) {
document.getElementById('result_box').textContent = JSON.stringify(obj, null, 2);
}
function postJson(url, body) {
return fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}).then(function (r) {
return r.json().catch(function () { return { ok: 0, message: 'Non-JSON response', http_code: r.status }; });
});
}
function run(op, options) {
options = options || {};
var url = opRoute[op];
if (!url) {
setResult({ ok: 0, message: 'Unsupported operation' });
return;
}
var payload = readPayload(op);
postJson(url, payload).then(function (res) {
if (!options.silent) {
setResult(res);
}
if ((res.ok || 0) === 1) {
var data = res.data || {};
var consentRef = (data.consentId || data.consentRequestId || data.consent_id || data.requestId || data.request_id || '').toString();
if (consentRef) {
document.getElementById('consent_id').value = consentRef;
document.getElementById('f_consent_id').value = consentRef;
}
var state = (data.workflow_state || data.consent_status || data.status || res.workflow_state || '').toString();
syncButtons(state);
if (op === 'consent_request') {
startConsentPolling();
}
if (op === 'consent_status' && isConsentApproved(state)) {
stopConsentPolling();
}
}
fetchTimeline();
}).catch(function (e) {
setResult({ ok: 0, message: e.message });
});
}
function startConsentPolling() {
stopConsentPolling();
consentPollTimer = setInterval(function () {
run('consent_status', { silent: true });
}, 15000);
}
function stopConsentPolling() {
if (consentPollTimer) {
clearInterval(consentPollTimer);
consentPollTimer = null;
}
}
function fetchTimeline() {
var qs = new URLSearchParams({
hfr_id: document.getElementById('f_hfr_id').value.trim(),
consent_id: document.getElementById('f_consent_id').value.trim(),
transaction_id: document.getElementById('f_transaction_id').value.trim(),
abha_address: document.getElementById('f_abha_address').value.trim(),
date_from: document.getElementById('f_date_from').value,
date_to: document.getElementById('f_date_to').value
});
fetch('<?= base_url('AbdmHiu/timeline') ?>?' + qs.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (r) { return r.json(); })
.then(function (res) {
var tbody = document.querySelector('#timeline_table tbody');
tbody.innerHTML = '';
(res.items || []).forEach(function (row) {
var tr = document.createElement('tr');
tr.innerHTML = '' +
'<td>' + (row.id || '') + '</td>' +
'<td>' + (row.created_at || '') + '</td>' +
'<td>' + (row.operation || '') + '</td>' +
'<td>' + (row.workflow_state || '') + '</td>' +
'<td>' + (row.status || '') + '</td>' +
'<td>' + (row.hfr_id || '') + '</td>' +
'<td>' + (row.abha_address || '') + '</td>' +
'<td>' + (row.consent_id || '') + '</td>' +
'<td>' + (row.transaction_id || '') + '</td>' +
'<td>' + (row.request_id || '') + '</td>' +
'<td>' + (row.last_error || '') + '</td>';
tbody.appendChild(tr);
});
});
}
document.getElementById('btnPatientSearch').addEventListener('click', searchPatients);
document.getElementById('patient_search').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
searchPatients();
}
});
document.querySelectorAll('button[data-op]').forEach(function (btn) {
btn.addEventListener('click', function () {
run(btn.getAttribute('data-op'));
});
});
document.getElementById('consent_id').addEventListener('input', function () {
syncButtons();
});
document.getElementById('btnApplyFilter').addEventListener('click', fetchTimeline);
document.getElementById('btnRefreshTimeline').addEventListener('click', fetchTimeline);
syncButtons();
fetchTimeline();
})();
</script>

View File

@ -16,6 +16,9 @@
<?php
$patientAbhaId = (string) ($data[0]->abha_id ?? $data[0]->abha_no ?? $data[0]->abha ?? $data[0]->abha_address ?? '');
$abhaAddress = trim((string) ($data[0]->abha_address ?? ''));
if ($abhaAddress === '' && preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', (string) ($data[0]->log ?? ''), $abhaLogMatch) === 1) {
$abhaAddress = trim((string) ($abhaLogMatch[1] ?? ''));
}
$abhaVerifiedStatus = trim((string) ($data[0]->abha_verified_status ?? ''));
$abhaVerificationType = trim((string) ($data[0]->abha_verification_type ?? ''));
$abhaKycVerified = (int) ($data[0]->abha_kyc_verified ?? 0) === 1;

View File

@ -408,6 +408,12 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) {
<span>ABDM Task Board</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('AbdmHiu') ?>','ABDM HIU M3')">
<i class="bi bi-diagram-3"></i>
<span>ABDM HIU M3</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('AbdmOpdQueue') ?>','ABDM OPD Queue')">
<i class="bi bi-people"></i>

View File

@ -0,0 +1,81 @@
# ABDM M3 HIU QA Report
## Build Info
- Date:
- Branch/Commit:
- Tester:
## Test Matrix
### 1. Happy path
- request -> status -> fetch -> health-information request
- Expected: workflow reaches COMPLETED
- Result:
- Evidence:
### 2. Missing token
- Remove/blank EATRIA_BRIDGE_TOKEN
- Expected: unauthorized/fail-fast error, no gateway call
- Result:
- Evidence:
### 3. Missing hfr_id
- Remove/blank ABDM_HFR_ID
- Expected: validation fail-fast
- Result:
- Evidence:
### 4. Wrong hfr_id/token mismatch
- Send payload hfr_id that mismatches configured ABDM_HFR_ID
- Expected: blocked with clear actionable message
- Result:
- Evidence:
### 5. Duplicate request_id replay
- Re-send same request_id + operation
- Expected: idempotent response, no duplicate row
- Result:
- Evidence:
### 6. Duplicate transaction_id replay
- Re-send same transaction_id + operation
- Expected: idempotent response, no duplicate row
- Result:
- Evidence:
### 7. Timeout/5xx retry behavior
- Simulate transient gateway failure
- Expected: retryable=1; queued for retry without duplicate workflow rows
- Result:
- Evidence:
### 8. Consent revoked flow
- status returns revoked
- Expected: workflow state REVOKED
- Result:
- Evidence:
### 9. Consent expired flow
- status returns expired
- Expected: workflow state EXPIRED
- Result:
- Evidence:
### 10. Audit completeness
- Verify request/response/status/error/hospital context persisted
- Result:
- Evidence:
## Defects
| ID | Severity | Scenario | Description | Status |
|----|----------|----------|-------------|--------|
## Final Signoff
- [ ] All 4 APIs integrated
- [ ] Full audit trail verified
- [ ] Hospital isolation by hfr_id verified
- [ ] All QA scenarios passed
- [ ] Runbook shared with support team
Signoff By:
Date:

View File

@ -0,0 +1,93 @@
# ABDM M3 HIU Runbook
## Scope
This runbook covers HMS-side M3 HIU integration to gateway endpoints:
- POST /api/v3/hiu/consent/request
- POST /api/v3/hiu/consent/request/status
- POST /api/v3/hiu/consent/request/fetch
- POST /api/v3/hiu/health-information/request
## Prerequisites
1. Hospital settings must include:
- EATRIA_BRIDGE_TOKEN
- ABDM_HFR_ID
- ABDM_HOSPITAL_ID (optional but recommended)
2. Permissions required:
- abdm.access OR abdm.taskboard.access OR abdm.gateway.use
## Deploy Steps
1. Pull latest code.
2. Run migration:
```bash
php spark migrate --namespace App
```
3. Verify new table exists:
- abdm_hiu_workflows
4. Open HIU UI:
- /AbdmHiu
## Operational Flow
1. Create Consent Request -> state REQUESTED
2. Check Consent Status -> state STATUS_CHECKED
3. Fetch Consent Artifact -> state CONSENT_FETCHED
4. Request Health Information -> state DATA_REQUESTED / COMPLETED
## Workflow States
- CREATED
- REQUESTED
- STATUS_CHECKED
- CONSENT_FETCHED
- DATA_REQUESTED
- COMPLETED
- FAILED
- REVOKED
- EXPIRED
## Idempotency
- request_id + operation: unique
- transaction_id + operation: unique
- Duplicate calls return previous result with duplicate=1
## Retry Policy
Only transient errors are retried:
- network/curl errors
- timeout
- HTTP 5xx
Command:
```bash
php spark abdm:hiu-retry --limit 20
```
## Audit Trail
All calls are logged in abdm_api_logs with:
- request_json
- response_json
- response_code
- status
- error_message
- endpoint
- event_type prefixed with abdm.m3.hiu.*
Workflow rows are stored in abdm_hiu_workflows including:
- request_id
- transaction_id
- consent_id
- abha_address
- hfr_id
- state/status timestamps
## Failure Handling
1. Missing token:
- Error: Missing token: set hospital_setting.EATRIA_BRIDGE_TOKEN
2. Missing hfr_id:
- Error: Missing hfr_id: set hospital_setting.ABDM_HFR_ID
3. Token/HFR mismatch:
- Error: hfr_id mismatch with configured ABDM_HFR_ID
## Support Checklist
1. Confirm token and hfr_id in hospital_setting.
2. Reproduce in /AbdmHiu.
3. Check timeline filters by hfr_id/consent_id/transaction_id/date.
4. Check abdm_api_logs for request_id and endpoint error.
5. Retry transient failures using spark command.