fix(abdm): correct mobile ABHA discovery flow

Route Find ABHA via Mobile through standalone bridge OTP endpoints and remove exact placeholder values from generated FHIR bundles.
This commit is contained in:
DevSoftTech Server 2026-08-24 23:19:21 +05:30
parent 2ac6babd5d
commit a7727c9d9e
8 changed files with 117 additions and 27 deletions

View File

@ -774,6 +774,8 @@ $routes->post('abha/create/initiate', 'Abha::initiate', ['filter' => 'permission
$routes->post('abha/create/verify_otp', 'Abha::verifyOtp', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/communication', 'Abha::communication', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/verify_comm_otp', 'Abha::verifyCommOtp', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/find/mobile/request-otp', 'Abha::communication', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/find/mobile/verify-otp', 'Abha::verifyCommOtp', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/confirm_patient', 'Abha::confirmPatient', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/address', 'Abha::address', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/create/address_suggestions', 'Abha::addressSuggestions', ['filter' => 'permission:abdm.abha.create']);

View File

@ -233,17 +233,20 @@ class Abha extends BaseController
$mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? '')));
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$isMobileDiscovery = (string) $this->request->getPost('find_abha') === '1';
if (strlen($mobile) !== 10) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Valid 10-digit mobile number is required']);
}
if ($txnId === '') {
if (! $isMobileDiscovery && $txnId === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Aadhaar enrolment txn_id is required']);
}
try {
$result = AbdmConnectorFactory::make()->abhaEnrolMobileRequestOtp(['mobile' => $mobile, 'txnId' => $txnId]);
$result = $isMobileDiscovery
? AbdmConnectorFactory::make()->abhaMobileGenerateOtp(['mobile' => $mobile])
: AbdmConnectorFactory::make()->abhaEnrolMobileRequestOtp(['mobile' => $mobile, 'txnId' => $txnId]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
@ -281,6 +284,7 @@ class Abha extends BaseController
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$otp = trim((string) ($this->request->getPost('otp') ?? ''));
$isMobileDiscovery = (string) $this->request->getPost('find_abha') === '1';
// The mobile itself isn't part of the OTP-verify payload (it's tied to txn_id
// on the bridge side), but the caller may resend it so we can persist it even
// if the gateway response doesn't echo it back.
@ -291,7 +295,9 @@ class Abha extends BaseController
}
try {
$result = AbdmConnectorFactory::make()->abhaEnrolMobileVerifyOtp(['txnId' => $txnId, 'otp' => $otp]);
$result = $isMobileDiscovery
? AbdmConnectorFactory::make()->abhaMobileVerifyOtp(['txnId' => $txnId, 'otp' => $otp])
: AbdmConnectorFactory::make()->abhaEnrolMobileVerifyOtp(['txnId' => $txnId, 'otp' => $otp]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
@ -319,7 +325,7 @@ class Abha extends BaseController
$abhaNum = (string) ($profile['ABHANumber'] ?? $profile['abha_id'] ?? $payload['ABHANumber'] ?? $payload['abha_id'] ?? '');
$expectedAbha = preg_replace('/\D/', '', (string) (session()->get('abha_enrol_identity_' . $txnId) ?? ''));
$returnedAbha = preg_replace('/\D/', '', $abhaNum);
if (strlen($expectedAbha) !== 14 || $returnedAbha !== $expectedAbha) {
if (! $isMobileDiscovery && (strlen($expectedAbha) !== 14 || $returnedAbha !== $expectedAbha)) {
log_message('error', '[ABHA] Enrolment identity mismatch blocked for txn ' . $txnId);
return $this->response->setStatusCode(409)->setJSON([
'ok' => 0,
@ -327,7 +333,9 @@ class Abha extends BaseController
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
session()->remove('abha_enrol_identity_' . $txnId);
if (! $isMobileDiscovery) {
session()->remove('abha_enrol_identity_' . $txnId);
}
$abhaAddress = (string) (
($profile['preferredAddress'] ?? '')
?: ($profile['preferredAbhaAddress'] ?? '')

View File

@ -293,7 +293,7 @@ class FhirDocumentBuilder
{
$clean = [];
foreach ($value as $k => $v) {
if ($v === null || $v === '') {
if ($v === null || $v === '' || $this->isPlaceholderValue($v)) {
continue;
}
if (is_array($v)) {
@ -310,6 +310,16 @@ class FhirDocumentBuilder
return $clean === [] ? null : $clean;
}
/** @param mixed $value */
private function isPlaceholderValue($value): bool
{
if (! is_string($value)) {
return false;
}
return in_array(strtoupper(trim($value)), ['NA', 'N/A', 'NULL', 'NOT AVAILABLE'], true);
}
private function newId(string $prefix): string
{
$prefix = strtolower(preg_replace('/[^a-z0-9]/i', '-', $prefix) ?: 'resource');

View File

@ -665,7 +665,7 @@ class FhirR4Builder
? 'OPD-' . ($encounter['id'] ?? $bundleUuid) . '-' . date('Y-m-d')
: $bundleUuid;
return [
return $this->sanitizeBundle([
'resourceType' => 'Bundle',
'id' => $bundleUuid,
'meta' => [
@ -680,7 +680,7 @@ class FhirR4Builder
'type' => 'document',
'timestamp' => $issuedAt,
'entry' => $allEntries,
];
]);
}
/**
@ -1110,7 +1110,7 @@ class FhirR4Builder
? 'LAB-' . (string) ($diagnosticReport['id'] ?? $bundleUuid)
: $bundleUuid;
return [
return $this->sanitizeBundle([
'resourceType' => 'Bundle',
'id' => $bundleUuid,
'meta' => [
@ -1125,7 +1125,7 @@ class FhirR4Builder
$resourceEntries,
$observationEntries
),
];
]);
}
/**
@ -1392,7 +1392,7 @@ class FhirR4Builder
? 'IPD-' . ($ipdCode !== '' ? $ipdCode : $bundleUuid)
: $bundleUuid;
return [
return $this->sanitizeBundle([
'resourceType' => 'Bundle',
'id' => $bundleUuid,
'meta' => [
@ -1406,7 +1406,7 @@ class FhirR4Builder
[['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]],
$resourceEntries
),
];
]);
}
/**
@ -1547,7 +1547,7 @@ class FhirR4Builder
$composition['author'] = [['display' => $provider]];
}
return [
return $this->sanitizeBundle([
'resourceType' => 'Bundle',
'type' => 'document',
'timestamp' => $issuedAt,
@ -1567,7 +1567,7 @@ class FhirR4Builder
], [
'resource' => $claimResource,
]],
];
]);
}
/**
@ -1866,7 +1866,7 @@ class FhirR4Builder
}
$hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? ''));
return [
return $this->sanitizeBundle([
'resourceType' => 'Bundle',
'id' => $bundleUuid,
'meta' => [
@ -1884,7 +1884,7 @@ class FhirR4Builder
'type' => 'document',
'timestamp' => $issuedAt,
'entry' => array_merge([['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]], $resourceEntries),
];
]);
}
private function normalizeImmunizationStatus(string $status): string
@ -1977,6 +1977,38 @@ class FhirR4Builder
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
/** @param array<string,mixed> $bundle
* @return array<string,mixed>
*/
private function sanitizeBundle(array $bundle): array
{
return $this->sanitizeFhirValue($bundle);
}
/** @param mixed $value
* @return mixed
*/
private function sanitizeFhirValue($value)
{
if (is_string($value)) {
return in_array(strtoupper(trim($value)), ['NA', 'N/A', 'NULL', 'NOT AVAILABLE'], true) ? null : $value;
}
if (! is_array($value)) {
return $value;
}
$clean = [];
foreach ($value as $key => $nested) {
$sanitized = $this->sanitizeFhirValue($nested);
if ($sanitized === null || $sanitized === '' || $sanitized === []) {
continue;
}
$clean[$key] = $sanitized;
}
return $clean;
}
/** ISO 8601 timestamp with India Standard Time offset (+05:30). */
private function isoTimestamp(): string
{

View File

@ -441,7 +441,7 @@
<div class="d-grid gap-2 mt-auto">
<button type="button" class="btn btn-success" onclick="abhaRegSelectMethod('number')"><i class="bi bi-person-vcard me-1"></i>By ABHA Number or ID</button>
<button type="button" class="btn btn-outline-primary" onclick="abhaRegSelectMethod('address')"><i class="bi bi-at me-1"></i>By ABHA Address</button>
<button type="button" class="btn btn-outline-secondary" onclick="abhaRegSelectMethod('mobile')"><i class="bi bi-phone me-1"></i>By Mobile OTP</button>
<button type="button" class="btn btn-outline-secondary" onclick="abhaRegSelectMethod('mobile')"><i class="bi bi-phone me-1"></i>Find ABHA via Mobile</button>
</div>
</div>
</div>
@ -759,12 +759,17 @@
<div class="small mb-1"><strong>Facility:</strong> <span id="abhareg_facility_name">—</span></div>
<div class="small"><strong>HFR ID:</strong> <span id="abhareg_facility_hfr">—</span></div>
</div>
<div class="small text-muted">Source: ABDM Bridge <code>GET /api/v3/facility/qr-code</code>.</div>
<div class="d-flex align-items-center gap-2 flex-wrap mt-3">
<button type="button" class="btn btn-primary btn-sm" onclick="load_form('<?= base_url('AbdmOpdQueue') ?>', 'ABDM OPD Queue');">
<i class="bi bi-ticket-perforated me-1"></i>Open ABDM OPD Queue
</button>
<span class="small text-muted">After the patient scans, confirm the incoming token here.</span>
</div>
</div>
</div>
</div>
<!-- Panel: Method 4 - Mobile OTP (handled by AbhaMobileModal) -->
<!-- Panel: Method 4 - Find ABHA by mobile (handled by AbhaMobileModal) -->
<div id="abhareg_panel_mobile" class="abhareg-panel d-none"></div>
<!-- Common result card (all methods) -->
@ -1341,7 +1346,7 @@
address: { icon: '@', title: 'By ABHA Address', sub: 'Secure modal verification' },
qr: { icon: '📷', title: 'Scan Patient ABHA QR', sub: 'Scan QR → patient details' },
facility: { icon: '🏥', title: 'Scan Facility QR', sub: 'Patient scans via ABHA app' },
mobile: { icon: '📱', title: 'By Mobile OTP', sub: 'OTP to ABHA-linked mobile' }
mobile: { icon: '📱', title: 'Find ABHA via Mobile', sub: 'Find an ABHA linked to this mobile number' }
};
window.abhaRegSelectMethod = function(method) {

View File

@ -271,7 +271,7 @@ $_aadh = '';
</div>
</div>
<!-- Mobile OTP Verify -->
<!-- Find ABHA via Mobile -->
<div class="tab-pane fade" id="cvpv-mobile" role="tabpanel">
<div id="cvpv-m-step1">
<div class="col-sm-6 mb-3">
@ -283,7 +283,7 @@ $_aadh = '';
</div>
<div id="cvpv_m_step1_msg"></div>
<button type="button" class="btn btn-primary" id="cvpv_btn_m_send_otp">
<i class="bi bi-send me-1"></i>Send OTP
<i class="bi bi-search me-1"></i>Find ABHA
</button>
</div>
<div id="cvpv-m-step2" style="display:none">

View File

@ -1,6 +1,6 @@
<?php
/**
* Partial: "Verify ABHA by Mobile OTP" modal.
* Partial: "Find ABHA via Mobile" modal.
*
* Mirrors partials/abha_verify_modal.php so both verification journeys look the
* same; only the identifier differs (ABHA-linked mobile instead of ABHA number).
@ -39,7 +39,7 @@
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-phone text-primary me-2"></i>Verify ABHA by Mobile OTP</h5>
<h5 class="modal-title"><i class="bi bi-phone text-primary me-2"></i>Find ABHA via Mobile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@ -57,7 +57,7 @@
<input type="text" class="form-control" id="abhaMobileNumber" maxlength="10" inputmode="numeric" autocomplete="tel" placeholder="10-digit mobile">
<button type="button" class="btn btn-primary" id="abhaMobileSendOtpBtn"><i class="bi bi-send me-1"></i>Send OTP</button>
</div>
<div class="form-text">Enter the mobile number registered with the patient's ABHA account.</div>
<div class="form-text">Enter the mobile number registered with the patient's ABHA account to find and link it.</div>
</section>
<section id="abhaMobileStep2" class="d-none">
@ -181,7 +181,7 @@ window.AbhaMobileModal = (function () {
verifiedMobile = mobile;
var button = $('#abhaMobileSendOtpBtn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Sending');
$.post('<?= base_url('abha/create/communication') ?>', { mobile: mobile, '<?= csrf_token() ?>': csrf() }, function (response) {
$.post('<?= base_url('abha/find/mobile/request-otp') ?>', { mobile: mobile, find_abha: 1, '<?= csrf_token() ?>': csrf() }, function (response) {
button.prop('disabled', false).html('<i class="bi bi-send me-1"></i>Send OTP');
if (!response || response.ok != 1) { alertBox('danger', apiMessage(response, 'Unable to send OTP to this mobile number.')); return; }
txnId = response.txn_id || '';
@ -201,7 +201,7 @@ window.AbhaMobileModal = (function () {
if (otp.length !== 6) { alertBox('warning', 'Enter the 6-digit OTP.'); return; }
var button = $('#abhaMobileVerifyBtn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Verifying');
$.post('<?= base_url('abha/create/verify_comm_otp') ?>', { otp: otp, txn_id: txnId, mobile: verifiedMobile, '<?= csrf_token() ?>': csrf() }, function (response) {
$.post('<?= base_url('abha/find/mobile/verify-otp') ?>', { otp: otp, txn_id: txnId, mobile: verifiedMobile, find_abha: 1, '<?= csrf_token() ?>': csrf() }, function (response) {
button.prop('disabled', false).html('<i class="bi bi-patch-check me-1"></i>Verify OTP');
if (!response || response.ok != 1) { alertBox('danger', apiMessage(response, 'OTP verification failed.')); return; }
stopTimer();

View File

@ -1,6 +1,7 @@
<?php
use App\Libraries\Abdm\Fhir\FhirGeneratorFactory;
use App\Libraries\Abdm\Fhir\FhirDocumentBuilder;
use App\Controllers\AbdmGateway;
use App\Controllers\Ipd_discharge;
use App\Libraries\FhirR4Builder;
@ -16,6 +17,38 @@ final class FhirGeneratorsTest extends CIUnitTestCase
$this->factory = new FhirGeneratorFactory();
}
public function testDocumentBuilderRemovesExactPlaceholderValues(): void
{
$bundle = (new FhirDocumentBuilder())
->buildBundleMeta('placeholder-test', '2026-08-24T10:00:00+05:30')
->buildComposition([
'resourceType' => 'Composition',
'id' => 'placeholder-composition',
'status' => 'final',
'title' => 'NA',
'subject' => ['display' => 'N/A'],
'note' => 'Nasal congestion',
])
->toBundle();
$composition = $bundle['entry'][0]['resource'];
$this->assertArrayNotHasKey('title', $composition);
$this->assertArrayNotHasKey('subject', $composition);
$this->assertSame('Nasal congestion', $composition['note']);
}
public function testLegacyBuilderRemovesExactPlaceholderValues(): void
{
$bundle = (new FhirR4Builder())->buildClaimBundle(
['id' => 'patient-1', 'name' => 'NA', 'gender' => 'female', 'birthDate' => '1990-01-01'],
['id' => 'encounter-1'],
['id' => 'claim-1', 'provider' => 'N/A']
);
$this->assertStringNotContainsString('"NA"', (string) json_encode($bundle));
$this->assertStringNotContainsString('"N/A"', (string) json_encode($bundle));
}
public function testImmunizationCareContextReferenceIsStableAcrossClinicalDateChanges(): void
{
$method = new ReflectionMethod(AbdmGateway::class, 'resolveImmunizationCareContextReference');