Update HIU workflow, patient view, and related assets

This commit is contained in:
DevSoftTech Server 2026-07-07 01:13:28 +05:30
parent f243aeeb04
commit 6407db9dbc
9 changed files with 907 additions and 46 deletions

View File

@ -0,0 +1,37 @@
<?php
namespace App\Commands;
use App\Libraries\Abdm\M3HiuWorkflowService;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class AbdmHiuPoll extends BaseCommand
{
protected $group = 'ABDM';
protected $name = 'abdm:hiu-poll';
protected $description = 'Poll NAT-safe gateway endpoints for consent reconciliation and decrypted HI data.';
protected $usage = 'abdm:hiu-poll [--limit 30]';
protected $arguments = [];
protected $options = [
'--limit' => 'Maximum recent workflow roots to poll in one run.',
];
public function run(array $params)
{
$limit = (int) (CLI::getOption('limit') ?? 30);
if ($limit <= 0) {
$limit = 30;
}
$service = new M3HiuWorkflowService();
$summary = $service->pollNatGateway($limit);
CLI::write('ABDM HIU NAT Poll Summary', 'yellow');
CLI::write('Processed: ' . (int) ($summary['processed'] ?? 0));
CLI::write('Consent updates: ' . (int) ($summary['consent_updates'] ?? 0), 'green');
CLI::write('Data updates: ' . (int) ($summary['data_updates'] ?? 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

@ -630,14 +630,19 @@ $routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCa
$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->get('AbdmHiu/poll_summary', 'AbdmHiu::pollSummary', ['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('AbdmHiu/consent_reconcile', 'AbdmHiu::consentReconcile', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/data_fetch', 'AbdmHiu::dataFetch', ['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]);
$routes->post('api/v1/hiu/consent/status', 'AbdmHiu::consentReconcile', ['filter' => $abdmPermFilter]);
$routes->post('api/v1/hiu/data/fetch', 'AbdmHiu::dataFetch', ['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');

View File

@ -43,6 +43,16 @@ class AbdmHiu extends BaseController
return $this->handleOperation('hi_request', 'DATA_REQUESTED');
}
public function consentReconcile()
{
return $this->handleOperation('consent_reconcile', 'STATUS_CHECKED');
}
public function dataFetch()
{
return $this->handleOperation('data_fetch', 'DATA_PENDING');
}
public function timeline()
{
$filters = [
@ -62,6 +72,17 @@ class AbdmHiu extends BaseController
]);
}
public function pollSummary()
{
$lookback = (int) ($this->request->getGet('lookback') ?? 180);
if ($lookback <= 0) {
$lookback = 180;
}
$summary = $this->service->natPollSummary($lookback);
return $this->response->setJSON($summary);
}
public function patientLookup()
{
if (! $this->db->tableExists('patient_master')) {

View File

@ -31,7 +31,17 @@ class M3HiuGatewayClient
public function healthInformationRequest(array $payload): array
{
return $this->call('/v3/hiu/health-information/request', $payload, 'health-information.request');
return $this->call('/v1/hiu/data/request', $payload, 'health-information.request');
}
public function reconcileConsentStatus(array $payload): array
{
return $this->callGet('/v1/hiu/consent/status', $payload, 'consent.status.reconcile');
}
public function fetchDecryptedData(array $payload): array
{
return $this->callGet('/v1/hiu/data/fetch', $payload, 'data.fetch');
}
private function call(string $path, array $payload, string $eventKey): array
@ -95,6 +105,10 @@ class M3HiuGatewayClient
: ('HTTP ' . $httpCode . ' non-JSON response');
}
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response ?? [])) {
$errorText = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.';
}
$retryable = 0;
if ($curlErr !== '' || $httpCode >= 500 || $httpCode === 0 || str_contains(strtolower($errorText), 'timed out')) {
$retryable = 1;
@ -106,6 +120,11 @@ class M3HiuGatewayClient
$response['error_text'] = $errorText;
$response['retryable'] = $retryable;
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response)) {
$response['retryable'] = 1;
$response['error_text'] = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.';
}
$gatewayRequestId = trim((string) ($response['request_id'] ?? $response['requestId'] ?? ''));
if ($gatewayRequestId === '') {
$gatewayRequestId = trim((string) ($payload['requestId'] ?? $payload['request_id'] ?? ''));
@ -117,8 +136,8 @@ class M3HiuGatewayClient
'consent_request_id',
'abdm_consent_request_id',
]);
if ($abdmConsentRequestId === '') {
$abdmConsentRequestId = trim((string) ($payload['consentRequestId'] ?? ''));
if ($abdmConsentRequestId !== '' && $this->isGatewayRefId($abdmConsentRequestId)) {
$abdmConsentRequestId = '';
}
if ($abdmConsentRequestId !== '') {
$response['abdm_consent_request_id'] = $abdmConsentRequestId;
@ -129,8 +148,8 @@ class M3HiuGatewayClient
'consentId',
'consent_id',
]);
if ($abdmConsentId === '') {
$abdmConsentId = trim((string) ($payload['consentId'] ?? $payload['consent_id'] ?? ''));
if ($abdmConsentId !== '' && $this->isGatewayRefId($abdmConsentId)) {
$abdmConsentId = '';
}
if ($abdmConsentId !== '') {
$response['consent_id'] = $abdmConsentId;
@ -149,6 +168,137 @@ class M3HiuGatewayClient
return $response;
}
private function callGet(string $path, array $query, 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) ($query['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,
];
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . $token,
];
if ($hospitalId !== '') {
$headers[] = 'X-Hospital-Id: ' . $hospitalId;
}
$cleanQuery = [];
foreach ($query as $k => $v) {
if ($v === null) {
continue;
}
$val = trim((string) $v);
if ($val === '') {
continue;
}
$cleanQuery[$k] = $val;
}
// Bridge polling APIs require hfr_id in query for auth scope validation.
if ($hfrId !== '') {
$cleanQuery['hfr_id'] = $hfrId;
}
$url = $this->baseUrl . $path;
if (! empty($cleanQuery)) {
$url .= '?' . http_build_query($cleanQuery);
}
$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 => 'GET',
]);
$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');
}
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $decoded ?? [])) {
$errorText = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.';
}
$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 ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response)) {
$response['retryable'] = 1;
$response['error_text'] = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.';
}
$gatewayRequestId = trim((string) ($response['request_id'] ?? $response['requestId'] ?? $cleanQuery['request_id'] ?? ''));
$response['gateway_request_id'] = $gatewayRequestId;
$abdmConsentRequestId = $this->findFirstValueByKeys($response, [
'consentRequestId',
'consent_request_id',
'abdm_consent_request_id',
]);
if ($abdmConsentRequestId !== '' && ! $this->isGatewayRefId($abdmConsentRequestId)) {
$response['abdm_consent_request_id'] = $abdmConsentRequestId;
$response['consent_request_id'] = $abdmConsentRequestId;
}
$abdmConsentId = $this->findFirstValueByKeys($response, [
'consentId',
'consent_id',
'abdm_consent_artifact_id',
]);
if ($abdmConsentId !== '' && ! $this->isGatewayRefId($abdmConsentId)) {
$response['consent_id'] = $abdmConsentId;
$response['abdm_consent_artifact_id'] = $abdmConsentId;
}
if (! isset($response['transaction_id'])) {
$response['transaction_id'] = (string) ($response['transactionId'] ?? $cleanQuery['transaction_id'] ?? '');
}
$this->writeApiLog($eventKey, $path, $cleanQuery, $response, $httpCode, $ok, $errorText, $hospitalId, 'GET');
return $response;
}
private function resolveRuntimeSettings(): array
{
if (! $this->db->tableExists('hospital_setting')) {
@ -207,7 +357,8 @@ class M3HiuGatewayClient
int $httpCode,
int $ok,
string $errorText,
string $hospitalId
string $hospitalId,
string $httpMethod = 'POST'
): void {
if (! $this->db->tableExists('abdm_api_logs')) {
return;
@ -229,7 +380,7 @@ class M3HiuGatewayClient
'channel' => 'eatria_bridge',
'event_type' => 'abdm.m3.hiu.' . $eventKey,
'endpoint' => $this->baseUrl . $path,
'http_method' => 'POST',
'http_method' => strtoupper(trim($httpMethod)) !== '' ? strtoupper(trim($httpMethod)) : 'POST',
'entity_type' => 'abdm_hiu',
'entity_id' => $entityId,
'request_json' => (string) json_encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
@ -268,4 +419,25 @@ class M3HiuGatewayClient
return '';
}
private function isGatewayRefId(string $value): bool
{
return preg_match('/^REQ-/i', trim($value)) === 1;
}
private function isCloudFrontBlockedResponse(int $httpCode, $raw, array $response): bool
{
if ($httpCode !== 403) {
return false;
}
$text = strtolower((string) $raw);
if ($text === '' && isset($response['data']['raw'])) {
$text = strtolower((string) $response['data']['raw']);
}
return str_contains($text, 'generated by cloudfront')
|| str_contains($text, 'request blocked')
|| str_contains($text, 'the request could not be satisfied');
}
}

View File

@ -50,7 +50,7 @@ class M3HiuWorkflowService
}
$dispatchPayload = (array) ($prepared['payload'] ?? []);
if ($hasWorkflowTable && $operation !== 'consent_status') {
if ($hasWorkflowTable && ! in_array($operation, ['consent_status', 'consent_reconcile', 'data_fetch', 'hi_request'], true)) {
$duplicate = $this->findDuplicate($operation, $normalized);
if ($duplicate !== null) {
$dupResponse = json_decode((string) ($duplicate['response_json'] ?? ''), true);
@ -70,8 +70,19 @@ class M3HiuWorkflowService
$status = $ok ? 'success' : 'failed';
$retryable = (int) ($result['retryable'] ?? 0) === 1;
$persistPayload = $dispatchPayload;
foreach ([
'request_id', 'requestId', 'transaction_id', 'transactionId',
'abha_address', 'consent_id', 'consentId',
'abdm_consent_request_id', 'abdm_consent_artifact_id', 'hfr_id',
] as $key) {
if (! array_key_exists($key, $persistPayload) && array_key_exists($key, $normalized)) {
$persistPayload[$key] = $normalized[$key];
}
}
if ($hasWorkflowTable) {
$this->upsertWorkflow($operation, $dispatchPayload, $result, $state, $status, $httpCode, $retryable);
$this->upsertWorkflow($operation, $persistPayload, $result, $state, $status, $httpCode, $retryable);
$result['workflow_persisted'] = 1;
} else {
$result['workflow_persisted'] = 0;
@ -129,6 +140,105 @@ class M3HiuWorkflowService
return $summary;
}
public function pollNatGateway(int $limit = 30): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return ['processed' => 0, 'consent_updates' => 0, 'data_updates' => 0, 'failed' => 0, 'skipped' => 0];
}
$summary = ['processed' => 0, 'consent_updates' => 0, 'data_updates' => 0, 'failed' => 0, 'skipped' => 0];
$consentRows = $this->db->table('abdm_hiu_workflows')
->select('*')
->where('operation', 'consent_request')
->where('status', 'success')
->orderBy('id', 'DESC')
->get($limit)
->getResultArray();
$seenConsentRequests = [];
foreach ($consentRows as $row) {
$requestId = trim((string) ($row['request_id'] ?? ''));
if ($requestId === '' || isset($seenConsentRequests[$requestId])) {
$summary['skipped']++;
continue;
}
$seenConsentRequests[$requestId] = true;
$summary['processed']++;
$payload = [
'request_id' => $requestId,
'transaction_id' => trim((string) ($row['transaction_id'] ?? '')),
'consent_id' => trim((string) ($row['consent_id'] ?? '')),
'abdm_consent_request_id' => trim((string) ($row['abdm_consent_request_id'] ?? '')),
'abdm_consent_artifact_id' => trim((string) ($row['abdm_consent_artifact_id'] ?? '')),
'abha_address' => trim((string) ($row['abha_address'] ?? '')),
'hfr_id' => $this->normalizeStoredHfrId((string) ($row['hfr_id'] ?? '')),
];
$consentResult = $this->runOperation('consent_reconcile', $payload);
if ((int) ($consentResult['ok'] ?? 0) === 1) {
$summary['consent_updates']++;
$resolvedConsentId = trim((string) ($consentResult['abdm_consent_artifact_id'] ?? $consentResult['consent_id'] ?? ''));
if ($resolvedConsentId !== '' && ! $this->hasSuccessfulHiRequest($requestId, $resolvedConsentId)) {
if (! $this->shouldAttemptHiRequest($requestId, $resolvedConsentId)) {
$summary['skipped']++;
continue;
}
$hiPayload = $payload;
$hiPayload['consent_id'] = $resolvedConsentId;
$hiPayload['abdm_consent_artifact_id'] = $resolvedConsentId;
$hiPayload['transaction_id'] = $this->generateFreshHiTransactionId($requestId, trim((string) ($payload['transaction_id'] ?? '')));
$hiResult = $this->runOperation('hi_request', $hiPayload);
if ((int) ($hiResult['ok'] ?? 0) !== 1) {
$summary['failed']++;
}
}
} else {
$summary['failed']++;
}
}
$hiRows = $this->db->table('abdm_hiu_workflows')
->select('*')
->where('operation', 'hi_request')
->where('status', 'success')
->orderBy('id', 'DESC')
->get($limit)
->getResultArray();
$seenTransactions = [];
foreach ($hiRows as $row) {
$transactionId = trim((string) ($row['transaction_id'] ?? ''));
$requestId = trim((string) ($row['request_id'] ?? ''));
$key = $transactionId !== '' ? $transactionId : $requestId;
if ($key === '' || isset($seenTransactions[$key])) {
$summary['skipped']++;
continue;
}
$seenTransactions[$key] = true;
$payload = [
'request_id' => $requestId,
'transaction_id' => $transactionId,
'consent_id' => trim((string) ($row['consent_id'] ?? '')),
'abdm_consent_artifact_id' => trim((string) ($row['abdm_consent_artifact_id'] ?? '')),
'abha_address' => trim((string) ($row['abha_address'] ?? '')),
'hfr_id' => $this->normalizeStoredHfrId((string) ($row['hfr_id'] ?? '')),
];
$dataResult = $this->runOperation('data_fetch', $payload);
if ((int) ($dataResult['ok'] ?? 0) === 1) {
$summary['data_updates']++;
} else {
$summary['failed']++;
}
}
return $summary;
}
public function listTimeline(array $filters, int $limit = 200): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
@ -165,6 +275,87 @@ class M3HiuWorkflowService
return $builder->get($limit)->getResultArray();
}
public function natPollSummary(int $lookbackMinutes = 180): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return [
'ok' => 0,
'message' => 'abdm_hiu_workflows table not found',
'last_polled_at' => null,
'consent_updates' => 0,
'data_updates' => 0,
'failed' => 0,
'pending' => 0,
'recent_errors' => [],
];
}
$fromTime = date('Y-m-d H:i:s', strtotime('-' . max(1, $lookbackMinutes) . ' minutes'));
$lastRow = $this->db->table('abdm_hiu_workflows')
->select('updated_at')
->whereIn('operation', ['consent_reconcile', 'data_fetch'])
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$rows = $this->db->table('abdm_hiu_workflows')
->select('operation, status, workflow_state')
->whereIn('operation', ['consent_reconcile', 'data_fetch'])
->where('updated_at >=', $fromTime)
->get()
->getResultArray();
$errorRows = $this->db->table('abdm_hiu_workflows')
->select('updated_at, operation, request_id, transaction_id, last_error')
->whereIn('operation', ['consent_reconcile', 'data_fetch'])
->where('status', 'failed')
->where('updated_at >=', $fromTime)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->get(8)
->getResultArray();
$consentUpdates = 0;
$dataUpdates = 0;
$failed = 0;
$pending = 0;
foreach ($rows as $row) {
$operation = trim((string) ($row['operation'] ?? ''));
$status = strtolower(trim((string) ($row['status'] ?? '')));
$state = strtolower(trim((string) ($row['workflow_state'] ?? '')));
if ($status === 'failed') {
$failed++;
continue;
}
if ($operation === 'consent_reconcile' && in_array($state, ['granted', 'status_checked'], true)) {
$consentUpdates++;
continue;
}
if ($operation === 'data_fetch' && in_array($state, ['data_received', 'completed'], true)) {
$dataUpdates++;
continue;
}
$pending++;
}
return [
'ok' => 1,
'last_polled_at' => $lastRow['updated_at'] ?? null,
'lookback_minutes' => $lookbackMinutes,
'consent_updates' => $consentUpdates,
'data_updates' => $dataUpdates,
'failed' => $failed,
'pending' => $pending,
'recent_errors' => $errorRows,
];
}
private function dispatch(string $operation, array $payload): array
{
return match ($operation) {
@ -172,6 +363,8 @@ class M3HiuWorkflowService
'consent_status' => $this->client->consentRequestStatus($payload),
'consent_fetch' => $this->client->consentRequestFetch($payload),
'hi_request' => $this->client->healthInformationRequest($payload),
'consent_reconcile' => $this->client->reconcileConsentStatus($payload),
'data_fetch' => $this->client->fetchDecryptedData($payload),
default => ['ok' => 0, 'http_code' => 400, 'error_text' => 'Unsupported operation: ' . $operation, 'retryable' => 0],
};
}
@ -335,18 +528,21 @@ class M3HiuWorkflowService
if ($operation === 'consent_status') {
$consentRequestId = trim((string) ($clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? $clean['consent_id'] ?? ''));
if ($consentRequestId === '') {
$consentRequestId = $this->resolveAbdmConsentRequestIdFromWorkflow($clean);
}
if ($consentRequestId === '') {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'consentRequestId is required for consent status.',
'error_text' => 'consentRequestId is required for consent status. Run consent request first or provide the ABDM consentRequestId from init response.',
];
}
if ($this->isGatewayRequestIdPattern($consentRequestId)) {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'mapping_error: consentRequestId appears to be gateway request_id; use stored ABDM consentRequestId.',
'error_text' => 'mapping_error: consentRequestId appears to be a local temporary request id; use consentRequestId returned by consent request/status.',
];
}
@ -369,7 +565,7 @@ class M3HiuWorkflowService
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'mapping_error: consentId appears to be gateway request_id; use ABDM consent artifact id only.',
'error_text' => 'mapping_error: consentId appears to be a local temporary request id; use ABDM consent artifact id only.',
];
}
@ -381,7 +577,10 @@ class M3HiuWorkflowService
if ($operation === 'hi_request') {
$hiRequest = $clean['hiRequest'] ?? null;
if (! is_array($hiRequest)) {
if (! is_array($hiRequest) || empty($hiRequest)) {
$hiRequest = $this->buildDefaultHiRequest($clean);
}
if (! is_array($hiRequest) || empty($hiRequest)) {
return [
'ok' => 0,
'http_code' => 422,
@ -403,9 +602,85 @@ class M3HiuWorkflowService
}
$hiRequest['consent']['id'] = $consentId;
$requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? ''));
$transactionId = $this->generateFreshHiTransactionId(
$requestIdRef,
trim((string) ($hiRequest['transactionId'] ?? $clean['transactionId'] ?? $clean['transaction_id'] ?? ''))
);
$hiRequest['transactionId'] = $transactionId;
if (! isset($hiRequest['dateRange']) || ! is_array($hiRequest['dateRange'])) {
$hiRequest['dateRange'] = [
'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-30 days')),
'to' => gmdate('Y-m-d\T00:00:00.000\Z'),
];
}
if (trim((string) ($hiRequest['dataPushUrl'] ?? '')) === '') {
$hiRequest['dataPushUrl'] = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/') . '/v3/hiu/data/push';
}
return [
'ok' => 1,
'payload' => $base + ['hiRequest' => $hiRequest],
'payload' => $base + ['transactionId' => $transactionId, 'transaction_id' => $transactionId, 'hiRequest' => $hiRequest],
];
}
if ($operation === 'consent_reconcile') {
$requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? ''));
$consentRequestId = trim((string) ($clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? ''));
$consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? ''));
if ($requestIdRef === '' && $consentRequestId === '' && $consentId === '') {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'One of request_id, consent_request_id, or consent_id is required for consent reconciliation.',
];
}
$query = [];
if ($requestIdRef !== '') {
$query['request_id'] = $requestIdRef;
}
if ($consentRequestId !== '' && ! $this->isGatewayRequestIdPattern($consentRequestId)) {
$query['consent_request_id'] = $consentRequestId;
}
if ($consentId !== '' && ! $this->isGatewayRequestIdPattern($consentId)) {
$query['consent_id'] = $consentId;
}
return [
'ok' => 1,
'payload' => $query,
];
}
if ($operation === 'data_fetch') {
$transactionId = trim((string) ($clean['transaction_id'] ?? $clean['transactionId'] ?? ''));
$consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? ''));
if ($consentId !== '' && $this->isGatewayRequestIdPattern($consentId)) {
$consentId = '';
}
if ($transactionId === '' && $consentId === '') {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'One of transaction_id or consent_id is required for data fetch polling.',
];
}
$query = [];
if ($transactionId !== '') {
$query['transaction_id'] = $transactionId;
}
if ($consentId !== '') {
$query['consent_id'] = $consentId;
}
return [
'ok' => 1,
'payload' => $query,
];
}
@ -501,6 +776,32 @@ class M3HiuWorkflowService
return $consent;
}
private function buildDefaultHiRequest(array $payload): array
{
$consentId = trim((string) ($payload['abdm_consent_artifact_id'] ?? $payload['consentId'] ?? $payload['consent_id'] ?? ''));
if ($consentId === '' || $this->isGatewayRequestIdPattern($consentId)) {
return [];
}
$requestIdRef = trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? ''));
$transactionId = $this->generateFreshHiTransactionId(
$requestIdRef,
trim((string) ($payload['transactionId'] ?? $payload['transaction_id'] ?? ''))
);
return [
'consent' => [
'id' => $consentId,
],
'dateRange' => [
'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-30 days')),
'to' => gmdate('Y-m-d\T00:00:00.000\Z'),
],
'dataPushUrl' => rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/') . '/v3/hiu/data/push',
'transactionId' => $transactionId,
];
}
private function readSettingValue(string $key): string
{
if (! $this->db->tableExists('hospital_setting')) {
@ -530,6 +831,26 @@ class M3HiuWorkflowService
return $envName;
}
private function resolveHospitalHfrId(): string
{
$value = $this->readSettingValue('ABDM_HFR_ID');
if ($value !== '') {
return $value;
}
return trim((string) (getenv('ABDM_HFR_ID') ?: ''));
}
private function normalizeStoredHfrId(string $value): string
{
$candidate = trim($value);
if ($candidate === '' || strcasecmp($candidate, 'UNKNOWN') === 0) {
return $this->resolveHospitalHfrId();
}
return $candidate;
}
private function resolveHiuServiceId(): string
{
if (! $this->db->tableExists('hospital_setting')) {
@ -617,6 +938,8 @@ class M3HiuWorkflowService
'consent_status' => 'STATUS_CHECKED',
'consent_fetch' => 'CONSENT_FETCHED',
'hi_request' => ($statusStr === 'completed' ? 'COMPLETED' : 'DATA_REQUESTED'),
'consent_reconcile' => ($statusStr === 'granted' ? 'GRANTED' : ($statusStr === 'revoked' ? 'REVOKED' : ($statusStr === 'expired' ? 'EXPIRED' : 'STATUS_CHECKED'))),
'data_fetch' => (($statusStr === 'completed' || isset($result['fhir_bundle']) || isset($result['bundles'])) ? 'DATA_RECEIVED' : 'DATA_PENDING'),
default => 'COMPLETED',
};
}
@ -650,6 +973,111 @@ class M3HiuWorkflowService
return null;
}
private function hasSuccessfulHiRequest(string $requestId, string $consentId): bool
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return false;
}
$builder = $this->db->table('abdm_hiu_workflows')
->select('id')
->where('operation', 'hi_request')
->where('status', 'success');
$builder->groupStart();
if ($requestId !== '') {
$builder->orWhere('request_id', $requestId);
}
if ($consentId !== '') {
$builder->orWhere('consent_id', $consentId);
if (in_array('abdm_consent_artifact_id', $this->db->getFieldNames('abdm_hiu_workflows') ?? [], true)) {
$builder->orWhere('abdm_consent_artifact_id', $consentId);
}
}
$builder->groupEnd();
$row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray();
return ! empty($row);
}
private function shouldAttemptHiRequest(string $requestId, string $consentId): bool
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return true;
}
$row = $this->latestHiRequestRow($requestId, $consentId);
if (empty($row)) {
return true;
}
$status = strtolower(trim((string) ($row['status'] ?? '')));
$retryable = (int) ($row['is_retryable'] ?? 0) === 1;
$retryCount = (int) ($row['retry_count'] ?? 0);
$nextRetryAt = trim((string) ($row['next_retry_at'] ?? ''));
if ($status !== 'failed' || ! $retryable) {
return true;
}
if ($nextRetryAt === '') {
return $retryCount < 4;
}
return strtotime($nextRetryAt) <= time();
}
private function latestHiRequestRow(string $requestId, string $consentId): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return [];
}
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
$builder = $this->db->table('abdm_hiu_workflows')
->select('id, status, is_retryable, retry_count, next_retry_at')
->where('operation', 'hi_request');
$builder->groupStart();
$hasCondition = false;
if ($requestId !== '') {
$builder->where('request_id', $requestId);
$hasCondition = true;
}
if ($consentId !== '') {
if ($hasCondition) {
$builder->orWhere('consent_id', $consentId);
} else {
$builder->where('consent_id', $consentId);
$hasCondition = true;
}
if (in_array('abdm_consent_artifact_id', $fields, true)) {
$builder->orWhere('abdm_consent_artifact_id', $consentId);
}
}
$builder->groupEnd();
if (! $hasCondition) {
return [];
}
return $builder->orderBy('id', 'DESC')->get(1)->getRowArray() ?? [];
}
private function generateFreshHiTransactionId(string $requestId, string $currentTransactionId): string
{
return $this->generateUuidV4();
}
private function generateUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private function upsertWorkflow(
string $operation,
array $payload,
@ -666,14 +1094,14 @@ class M3HiuWorkflowService
$hmsRequestId = trim((string) ($result['hms_request_id'] ?? ''));
}
$gatewayRequestId = trim((string) ($result['gateway_request_id'] ?? $result['request_id'] ?? $result['requestId'] ?? ''));
$abdmConsentRequestId = trim((string) ($result['abdm_consent_request_id'] ?? $result['consent_request_id'] ?? $payload['consentRequestId'] ?? ''));
$abdmConsentRequestId = trim((string) ($result['abdm_consent_request_id'] ?? $result['consent_request_id'] ?? $payload['consentRequestId'] ?? $payload['consent_request_id'] ?? ''));
$transactionId = trim((string) ($payload['transaction_id'] ?? $payload['transactionId'] ?? $result['transaction_id'] ?? ''));
$consentId = trim((string) ($payload['abdm_consent_artifact_id'] ?? $payload['consent_id'] ?? $payload['consentId'] ?? $result['abdm_consent_artifact_id'] ?? $result['consent_id'] ?? ''));
$consentId = trim((string) ($payload['abdm_consent_artifact_id'] ?? $payload['consent_id'] ?? $payload['consentId'] ?? $result['abdm_consent_artifact_id'] ?? $result['consent_id'] ?? $result['consentId'] ?? ''));
if ($consentId === '' && $operation !== 'consent_fetch' && $operation !== 'hi_request') {
$consentId = $abdmConsentRequestId;
}
$abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abha_id'] ?? $result['abha_address'] ?? ''));
$hfrId = trim((string) ($payload['hfr_id'] ?? $result['hfr_id'] ?? ''));
$hfrId = $this->normalizeStoredHfrId((string) ($payload['hfr_id'] ?? $result['hfr_id'] ?? ''));
$hospitalId = trim((string) ($payload['hospital_id'] ?? $result['hospital_id'] ?? ''));
$errorText = trim((string) ($result['error_text'] ?? $result['message'] ?? ''));
@ -702,7 +1130,7 @@ class M3HiuWorkflowService
$row = [
'hospital_id' => $hospitalId !== '' ? $hospitalId : null,
'hfr_id' => $hfrId !== '' ? $hfrId : 'UNKNOWN',
'hfr_id' => $hfrId !== '' ? $hfrId : null,
'abha_address' => $abhaAddress !== '' ? $abhaAddress : null,
'consent_id' => $consentId !== '' ? $consentId : null,
'request_id' => $hmsRequestId !== '' ? $hmsRequestId : null,
@ -754,6 +1182,84 @@ class M3HiuWorkflowService
private function isGatewayRequestIdPattern(string $value): bool
{
return preg_match('/^REQ-\d{14}-[A-Za-z0-9]{6,}$/', trim($value)) === 1;
return preg_match('/^REQ-/i', trim($value)) === 1;
}
private function resolveAbdmConsentRequestIdFromWorkflow(array $payload): string
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return '';
}
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
$hasAbdmConsentRequestId = in_array('abdm_consent_request_id', $fields, true);
$requestId = trim((string) ($payload['requestId'] ?? $payload['request_id'] ?? ''));
$transactionId = trim((string) ($payload['transactionId'] ?? $payload['transaction_id'] ?? ''));
$abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abha_id'] ?? ''));
$tryBuilder = function (?string $whereCol, string $whereVal) use ($hasAbdmConsentRequestId): string {
if ($whereCol === null || $whereVal === '') {
return '';
}
$select = ['consent_id', 'response_json'];
if ($hasAbdmConsentRequestId) {
$select[] = 'abdm_consent_request_id';
}
$rows = $this->db->table('abdm_hiu_workflows')
->select(implode(', ', $select))
->where($whereCol, $whereVal)
->whereIn('operation', ['consent_request', 'consent_status'])
->orderBy('id', 'DESC')
->get(25)
->getResultArray();
foreach ($rows as $row) {
$candidate = '';
if ($hasAbdmConsentRequestId) {
$candidate = trim((string) ($row['abdm_consent_request_id'] ?? ''));
}
if ($candidate === '') {
$candidate = trim((string) ($row['consent_id'] ?? ''));
}
if ($candidate !== '' && ! $this->isGatewayRequestIdPattern($candidate)) {
return $candidate;
}
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
if (is_array($decoded)) {
$candidate = trim((string) (
$decoded['abdm_consent_request_id']
?? $decoded['consent_request_id']
?? $decoded['consentRequestId']
?? ''
));
if ($candidate !== '' && ! $this->isGatewayRequestIdPattern($candidate)) {
return $candidate;
}
}
}
return '';
};
$resolved = $tryBuilder('request_id', $requestId);
if ($resolved !== '') {
return $resolved;
}
$resolved = $tryBuilder('transaction_id', $transactionId);
if ($resolved !== '') {
return $resolved;
}
$resolved = $tryBuilder('abha_address', $abhaAddress);
if ($resolved !== '') {
return $resolved;
}
return '';
}
}

View File

@ -3,10 +3,19 @@
<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>
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0">ABDM M3 HIU Console</h5>
<span id="poll_summary_badge" class="badge bg-secondary">Poll: waiting</span>
<button class="btn btn-sm btn-outline-warning" id="btnPollErrors" type="button">Show Poll Errors</button>
</div>
<button class="btn btn-sm btn-outline-primary" id="btnRefreshTimeline">Refresh Timeline</button>
</div>
<div class="card-body">
<div id="poll_error_panel" class="alert alert-warning d-none mb-3">
<div class="fw-bold mb-1">Recent Poll Errors</div>
<ul id="poll_error_list" class="mb-0 ps-3"></ul>
</div>
<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>
@ -118,13 +127,16 @@
var consentPollTimer = null;
var consentPollAttempts = 0;
var consentPollMaxAttempts = 12;
var recentPollErrors = [];
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') ?>'
consent_status: '<?= base_url('AbdmHiu/consent_reconcile') ?>',
consent_fetch: '<?= base_url('AbdmHiu/consent_reconcile') ?>',
hi_request: '<?= base_url('AbdmHiu/health_information_request') ?>',
data_fetch: '<?= base_url('AbdmHiu/data_fetch') ?>'
};
var pollSummaryUrl = '<?= base_url('AbdmHiu/poll_summary') ?>';
var patientLookupUrl = '<?= base_url('AbdmHiu/patient_lookup') ?>';
@ -183,7 +195,7 @@
function isConsentApproved(statusText) {
var s = (statusText || '').toString().trim().toLowerCase();
return ['approved', 'granted', 'active', 'linked', 'completed'].indexOf(s) !== -1;
return ['approved', 'granted', 'active', 'linked', 'completed', 'status_checked'].indexOf(s) !== -1;
}
function isTerminalConsentState(statusText) {
@ -192,7 +204,7 @@
}
function isGatewayRequestId(v) {
return /^REQ-\d{14}-[A-Za-z0-9]{6,}$/.test((v || '').toString().trim());
return /^REQ-/i.test((v || '').toString().trim());
}
function isValidConsentArtifactId(v) {
@ -204,12 +216,15 @@
var hasPatient = !!selectedPatient;
var hasNum = hasPatient && selectedPatient.has_abha_number === 1;
var hasAddr = hasPatient && selectedPatient.has_abha_address === 1;
var hasConsentRequestRef = (getFieldValue(['consent_request_id', 'consent_id']) !== '');
var hasConsentArtifactRef = (getFieldValue(['consent_artifact_id', 'consent_id']) !== '');
var consentRequestRef = getFieldValue(['consent_request_id', 'consent_id']);
var consentArtifactRef = getFieldValue(['consent_artifact_id', 'consent_id']);
var hasConsentRequestRef = (consentRequestRef !== '' && !isGatewayRequestId(consentRequestRef));
var hasConsentArtifactRef = (consentArtifactRef !== '' && !isGatewayRequestId(consentArtifactRef));
var canFetchByState = isConsentApproved(consentStateText) || isTerminalConsentState(consentStateText);
document.getElementById('btnConsentRequest').disabled = !(hasNum && hasAddr);
document.getElementById('btnConsentStatus').disabled = !hasConsentRequestRef;
document.getElementById('btnConsentFetch').disabled = !hasConsentArtifactRef;
document.getElementById('btnConsentFetch').disabled = !(hasConsentArtifactRef || (hasConsentRequestRef && canFetchByState));
document.getElementById('btnHiRequest').disabled = !(hasConsentArtifactRef && isConsentApproved(consentStateText));
updateReadinessBox(consentStateText);
}
@ -218,7 +233,6 @@
selectedPatient = p;
setFieldValue(['selected_patient'], (p.patient_name || '') + (p.patient_code ? ' (' + p.patient_code + ')' : ''));
setFieldValue(['abha_address'], p.abha_address || '');
setFieldValue(['f_abha_address'], p.abha_address || '');
setFieldValue(['request_id'], genReq('REQ-CONSENT'));
setFieldValue(['transaction_id'], genReq('TXN-HIU'));
@ -382,15 +396,24 @@
return base;
}
if (op === 'consent_status') {
if (op === 'consent_status' || op === 'consent_fetch') {
var requestIdRef = (raw.request_id || raw.requestId || '').toString().trim();
var consentRequestId = (raw.abdm_consent_request_id || raw.consentRequestId || '').toString().trim();
if (!consentRequestId) {
throw new Error('consentRequestId is required for consent status.');
var consentIdRef = (raw.abdm_consent_artifact_id || raw.consentId || raw.consent_id || '').toString().trim();
if (!requestIdRef && !consentRequestId && !consentIdRef) {
throw new Error('Provide one of request_id, consentRequestId, or consentId for consent reconciliation.');
}
if (isGatewayRequestId(consentRequestId)) {
throw new Error('mapping_error: consentRequestId looks like gateway request_id (REQ-...). Use actual ABDM consentRequestId from init response.');
if (requestIdRef) {
base.request_id = requestIdRef;
}
if (consentRequestId && !isGatewayRequestId(consentRequestId)) {
base.consent_request_id = consentRequestId;
}
if (consentIdRef && !isGatewayRequestId(consentIdRef)) {
base.consent_id = consentIdRef;
}
base.consentRequestId = consentRequestId;
return base;
}
@ -400,7 +423,7 @@
throw new Error('consentId is required for consent fetch.');
}
if (isGatewayRequestId(consentId)) {
throw new Error('mapping_error: consentId looks like gateway request_id (REQ-...). Use ABDM consent artifact id only.');
throw new Error('mapping_error: consentId looks like gateway/local request_id (REQ-...). Use ABDM consent artifact id only.');
}
base.consentId = consentId;
return base;
@ -409,7 +432,17 @@
if (op === 'hi_request') {
var hiRequest = raw.hiRequest;
if (!hiRequest || typeof hiRequest !== 'object' || Array.isArray(hiRequest)) {
throw new Error('hiRequest object is required for health information request.');
hiRequest = {
consent: {
id: (raw.consentId || raw.consent_id || '').toString().trim()
},
dateRange: {
from: new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)).toISOString(),
to: new Date().toISOString()
},
dataPushUrl: 'https://abdm-bridge.e-atria.in/api/v3/hiu/data/push',
transactionId: (raw.transactionId || raw.transaction_id || '').toString().trim() || genReq('TXN-HIU')
};
}
if (!hiRequest.consent || typeof hiRequest.consent !== 'object') {
@ -422,7 +455,13 @@
throw new Error('hiRequest.consent.id is required for health information request.');
}
if (!hiRequest.transactionId) {
hiRequest.transactionId = (raw.transactionId || raw.transaction_id || '').toString().trim() || genReq('TXN-HIU');
}
setFieldValue(['transaction_id'], hiRequest.transactionId);
base.hiRequest = hiRequest;
base.transactionId = hiRequest.transactionId;
return base;
}
@ -484,20 +523,18 @@
if (op === 'consent_request') {
startConsentPolling();
}
if (op === 'consent_status' && isTerminalConsentState(state)) {
if ((op === 'consent_status' || op === 'consent_fetch') && isConsentApproved(state) && isValidConsentArtifactId(consentArtifactRef)) {
run('hi_request', { silent: true });
}
if ((op === 'consent_status' || op === 'consent_fetch') && isTerminalConsentState(state)) {
stopConsentPolling();
}
} else if (op === 'consent_status') {
} else if (op === 'consent_status' || op === 'consent_fetch') {
var errState = ((res.data || {}).workflow_state || res.workflow_state || '').toString();
var errText = (res.error || ((res.data || {}).error_text) || '').toString().toLowerCase();
if (isTerminalConsentState(errState) || errText.indexOf('mapping_error') !== -1) {
stopConsentPolling();
}
} else if (op === 'consent_fetch') {
var fetchErr = (res.error || ((res.data || {}).error_text) || '').toString().toLowerCase();
if (fetchErr.indexOf('mapping_error') !== -1) {
setFieldValue(['consent_artifact_id', 'consent_id'], '');
}
}
fetchTimeline();
@ -528,6 +565,70 @@
consentPollAttempts = 0;
}
function renderPollErrors() {
var list = document.getElementById('poll_error_list');
if (!list) {
return;
}
list.innerHTML = '';
if (!recentPollErrors.length) {
var li = document.createElement('li');
li.textContent = 'No recent polling errors in selected lookback window.';
list.appendChild(li);
return;
}
recentPollErrors.forEach(function (row) {
var when = (row.updated_at || '-').toString();
var op = (row.operation || '-').toString();
var req = (row.request_id || '-').toString();
var err = (row.last_error || 'Unknown error').toString();
var li = document.createElement('li');
li.textContent = when + ' | ' + op + ' | ' + req + ' | ' + err;
list.appendChild(li);
});
}
function refreshPollSummary() {
fetch(pollSummaryUrl + '?lookback=180', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (r) { return r.json(); })
.then(function (res) {
var badge = document.getElementById('poll_summary_badge');
if (!badge) {
return;
}
if ((res.ok || 0) !== 1) {
badge.className = 'badge bg-danger';
badge.textContent = 'Poll: unavailable';
return;
}
var consentUpdates = parseInt(res.consent_updates || 0, 10);
var dataUpdates = parseInt(res.data_updates || 0, 10);
var failed = parseInt(res.failed || 0, 10);
var pending = parseInt(res.pending || 0, 10);
var lastAt = (res.last_polled_at || '-').toString();
recentPollErrors = Array.isArray(res.recent_errors) ? res.recent_errors : [];
renderPollErrors();
badge.className = failed > 0 ? 'badge bg-warning text-dark' : 'badge bg-success';
badge.textContent = 'Poll c:' + consentUpdates + ' d:' + dataUpdates + ' f:' + failed + ' p:' + pending + ' | ' + lastAt;
})
.catch(function () {
var badge = document.getElementById('poll_summary_badge');
if (!badge) {
return;
}
badge.className = 'badge bg-danger';
badge.textContent = 'Poll: unavailable';
recentPollErrors = [];
renderPollErrors();
});
}
function fetchTimeline() {
var qs = new URLSearchParams({
hfr_id: getFieldValue(['f_hfr_id']),
@ -561,6 +662,8 @@
'<td>' + (row.last_error || '') + '</td>';
tbody.appendChild(tr);
});
refreshPollSummary();
});
}
@ -606,8 +709,25 @@
if (btnRefreshTimeline) {
btnRefreshTimeline.addEventListener('click', fetchTimeline);
}
var btnPollErrors = document.getElementById('btnPollErrors');
if (btnPollErrors) {
btnPollErrors.addEventListener('click', function () {
var panel = document.getElementById('poll_error_panel');
if (!panel) {
return;
}
panel.classList.toggle('d-none');
if (panel.classList.contains('d-none')) {
btnPollErrors.textContent = 'Show Poll Errors';
} else {
renderPollErrors();
btnPollErrors.textContent = 'Hide Poll Errors';
}
});
}
syncButtons();
refreshPollSummary();
fetchTimeline();
})();
</script>

View File

@ -500,10 +500,10 @@
<div class="tab-pane fade" id="abha-register" role="tabpanel" aria-labelledby="abhareg-tab">
<div class="row">
<div class="col-12">
<h6 class="fw-bold mb-3"><i class="bi bi-person-check-fill me-2 text-primary"></i>ABHA-Based Patient Registration</h6>
<h6 class="fw-bold mb-3"><i class="bi bi-person-check-fill me-2 text-primary" style="margin-left: 10px;margin-right: 10px;"></i>ABHA-Based Patient Registration</h6>
<!-- Method selector cards -->
<div id="abhareg_methods" class="row g-2 mb-3">
<div id="abhareg_methods" class="row g-2 mb-3" style="margin-left: 10px;margin-right: 10px;">
<div class="col-6 col-md-3">
<div class="card border abhareg-method-card text-center py-3" data-method="number" role="button" tabindex="0" onclick="abhaRegSelectMethod('number')" style="cursor:pointer">
<div class="fs-2 mb-1">🆔</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB