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:
parent
2ac6babd5d
commit
a7727c9d9e
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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'] ?? '')
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user