From 156d12adba664e4a0ee216e582065802cf5b45da Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 3 Sep 2026 16:17:46 +0530 Subject: [PATCH] Implement ABDM M1 multi-ABHA account selection for Mobile OTP login flow --- app/Config/Routes.php | 1 + app/Controllers/Abha.php | 147 +++++++++++++ app/Libraries/Abdm/AbdmConnectorInterface.php | 8 + app/Libraries/Abdm/DirectAbdmConnector.php | 5 + app/Libraries/Abdm/DreamsoftConnector.php | 5 + app/Libraries/Abdm/EAtriaBridgeConnector.php | 20 ++ app/Views/partials/abha_mobile_modal.php | 198 +++++++++++++++++- 7 files changed, 375 insertions(+), 9 deletions(-) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index a4ea818..ec5e7ee 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -814,6 +814,7 @@ $routes->post('abha/create/communication', 'Abha::communication', ['filter' => ' $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/find/mobile/select-account', 'Abha::selectAccount', ['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']); diff --git a/app/Controllers/Abha.php b/app/Controllers/Abha.php index c1a0a8f..3e19442 100644 --- a/app/Controllers/Abha.php +++ b/app/Controllers/Abha.php @@ -376,10 +376,19 @@ class Abha extends BaseController 'email' => $email, ]; + $rawAccounts = is_array($payload['accounts'] ?? null) + ? $payload['accounts'] + : (is_array($result['data']['accounts'] ?? null) ? $result['data']['accounts'] : []); + $txnIdOut = (string) ($payload['txnId'] ?? $payload['txn_id'] ?? $result['txn_id'] ?? $result['data']['txnId'] ?? $txnId); + $tokenOut = (string) ($payload['token'] ?? $result['data']['token'] ?? $result['token'] ?? ''); + $patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $mobile, $gender, $dob, $abhaMeta); $responseBase = [ 'ok' => 1, + 'txn_id' => $txnIdOut, + 'token' => $tokenOut, + 'accounts' => $rawAccounts, 'card_base64' => $this->extractAbhaCardData($result), 'card_content_type' => $this->resolveAbhaCardContentType($result), 'card_source' => $this->resolveAbhaCardSource($result), @@ -427,6 +436,144 @@ class Abha extends BaseController ]); } + // ------------------------------------------------------------------------- + // Multi-Account Selection for Mobile OTP (ABDM M1) + // POST abha/find/mobile/select-account + // ------------------------------------------------------------------------- + public function selectAccount() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? '')); + $token = trim((string) ($this->request->getPost('token') ?? '')); + $abhaNumber = trim((string) ($this->request->getPost('abha_number') ?? $this->request->getPost('abhaNumber') ?? '')); + $abhaAddress = trim((string) ($this->request->getPost('abha_address') ?? $this->request->getPost('abhaAddress') ?? '')); + $mobile = trim((string) ($this->request->getPost('mobile') ?? '')); + + if ($txnId === '' && $token === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'txn_id and token are required']); + } + + try { + $result = AbdmConnectorFactory::make()->abhaLoginSelectAccount([ + 'txnId' => $txnId, + 'token' => $token, + 'abha_number' => $abhaNumber, + 'abha_address' => $abhaAddress, + ]); + } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]); + } + + if (empty($result['ok']) || $result['ok'] != 1) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => $this->extractBridgeErrorText($result, 'Account selection failed'), + 'request_id' => (string) ($result['request_id'] ?? ''), + ]); + } + + $payload = $result; + if (is_array($result['data'] ?? null)) { + foreach ($result['data'] as $key => $value) { + if (! array_key_exists($key, $payload)) { + $payload[$key] = $value; + } + } + } + + $profile = $this->pickGatewayAbhaProfile($payload); + $abhaNum = (string) ($profile['ABHANumber'] ?? $profile['abha_id'] ?? $payload['ABHANumber'] ?? $payload['abha_id'] ?? $abhaNumber); + $resAbhaAddress = (string) ( + ($profile['preferredAddress'] ?? '') + ?: ($profile['preferredAbhaAddress'] ?? '') + ?: ($profile['abha_address'] ?? '') + ?: ($payload['preferredAddress'] ?? '') + ?: ($payload['preferredAbhaAddress'] ?? '') + ?: ($payload['abha_address'] ?? '') + ?: $abhaAddress + ); + $name = $this->extractAbhaProfileName($profile, $payload); + $photo = $this->extractAbhaProfilePhoto($profile, $payload); + $gender = (string) ($profile['gender'] ?? $payload['gender'] ?? ''); + $dob = (string) ($profile['dob'] ?? $profile['date_of_birth'] ?? $payload['dob'] ?? $payload['date_of_birth'] ?? ''); + $resMobile = (string) ($profile['mobile'] ?? $payload['mobile'] ?? $payload['mobileNumber'] ?? $mobile); + $verifiedStatus = (string) (($payload['gateway_abha_profile']['status'] ?? '') ?: ($profile['verifiedStatus'] ?? '') ?: ($profile['status'] ?? '') ?: ($payload['verifiedStatus'] ?? '') ?: ($payload['status'] ?? '')); + $verificationType = (string) (($payload['gateway_abha_profile']['abha_type'] ?? '') ?: ($profile['verificationType'] ?? '') ?: ($profile['abhaType'] ?? '') ?: ($payload['verificationType'] ?? '') ?: ($payload['abhaType'] ?? '')); + $kycVerified = $profile['kycVerified'] ?? $payload['kycVerified'] ?? null; + $mobileVerified = $profile['mobileVerified'] ?? $payload['mobileVerified'] ?? null; + $address = (string) (($profile['address'] ?? '') ?: ($profile['address_line'] ?? '') ?: ($payload['address'] ?? '') ?: ($payload['address_line'] ?? '') ?: ($payload['gateway_abha_profile']['address'] ?? '') ?: ($payload['gateway_patient']['address_line'] ?? '')); + $zip = (string) (($profile['pinCode'] ?? '') ?: ($profile['pin_code'] ?? '') ?: ($payload['pinCode'] ?? '') ?: ($payload['pin_code'] ?? '') ?: ($payload['gateway_abha_profile']['pin_code'] ?? '') ?: ($payload['gateway_patient']['pincode'] ?? '')); + $stateName = (string) (($profile['stateName'] ?? '') ?: ($profile['state_name'] ?? '') ?: ($payload['stateName'] ?? '') ?: ($payload['state_name'] ?? '') ?: ($payload['gateway_abha_profile']['state_name'] ?? '') ?: ($payload['gateway_patient']['state_name'] ?? '')); + $districtName = (string) (($profile['districtName'] ?? '') ?: ($profile['district_name'] ?? '') ?: ($payload['districtName'] ?? '') ?: ($payload['district_name'] ?? '') ?: ($payload['gateway_abha_profile']['district_name'] ?? '') ?: ($payload['gateway_patient']['district'] ?? '')); + $email = (string) (($profile['email'] ?? '') ?: ($payload['email'] ?? '') ?: ($payload['gateway_abha_profile']['email'] ?? '') ?: ($payload['gateway_patient']['email'] ?? '')); + + $abhaMeta = [ + 'abha_address' => $resAbhaAddress, + 'profile_photo' => $photo, + 'verified_status' => $verifiedStatus, + 'verification_type' => $verificationType, + 'kyc_verified' => $kycVerified, + 'mobile_verified' => $mobileVerified, + 'address' => $address, + 'district' => $districtName, + 'state' => $stateName, + 'zip' => $zip, + 'email' => $email, + ]; + + $patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $resMobile, $gender, $dob, $abhaMeta); + + $responseBase = [ + 'ok' => 1, + 'card_base64' => $this->extractAbhaCardData($result), + 'card_content_type' => $this->resolveAbhaCardContentType($result), + 'card_source' => $this->resolveAbhaCardSource($result), + 'card_message' => $this->resolveAbhaCardMessage($result), + 'abha_number' => $abhaNum, + 'name' => $name, + 'photo' => $photo, + 'mobile' => $resMobile, + 'gender' => $gender, + 'dob' => $dob, + 'abha_address' => $resAbhaAddress, + 'verified_status' => $verifiedStatus, + 'verification_type' => $verificationType, + 'kyc_verified' => $kycVerified, + 'mobile_verified' => $mobileVerified, + 'address' => $address, + 'district' => $districtName, + 'state' => $stateName, + 'zip' => $zip, + 'email' => $email, + ]; + + if ($patientInfo !== null) { + return $this->response->setJSON($responseBase + [ + 'need_confirmation' => false, + 'patient_id' => $patientInfo['patient_id'], + 'p_code' => $patientInfo['p_code'], + 'is_new_patient' => $patientInfo['is_new'], + ]); + } + + $db = \Config\Database::connect(); + $fields = $db->getFieldNames('patient_master') ?? []; + $abhaField = $this->resolveAbhaFieldName($fields); + $abhaNumClean = preg_replace('/\D/', '', $abhaNum); + $candidates = $this->findMatchingCandidates($db, $fields, $name, $resMobile, $gender, $dob, '', $abhaField, $abhaNumClean); + + return $this->response->setJSON($responseBase + [ + 'need_confirmation' => true, + 'patient_id' => 0, + 'p_code' => '', + 'is_new_patient' => null, + 'candidates' => $candidates, + ]); + } + // ------------------------------------------------------------------------- // Step 4 — ABHA address suggestions / assignment // POST abha/create/address_suggestions, POST abha/create/address diff --git a/app/Libraries/Abdm/AbdmConnectorInterface.php b/app/Libraries/Abdm/AbdmConnectorInterface.php index 3451f05..7dab9c7 100644 --- a/app/Libraries/Abdm/AbdmConnectorInterface.php +++ b/app/Libraries/Abdm/AbdmConnectorInterface.php @@ -119,6 +119,14 @@ interface AbdmConnectorInterface public function abhaEnrolMobileVerifyOtp(array $payload): array; + /** + * ABDM M1: Select a specific ABHA account when multiple accounts are linked to mobile. + * Calls gateway POST /api/v3/abha/login/select-account + * + * @param array $payload Must contain 'txnId', 'token', 'abha_number', 'abha_address' + */ + public function abhaLoginSelectAccount(array $payload): array; + /** * ABDM M1: List ABHA address suggestions for an in-progress enrolment. * diff --git a/app/Libraries/Abdm/DirectAbdmConnector.php b/app/Libraries/Abdm/DirectAbdmConnector.php index 9f65ca2..1c9b5f7 100644 --- a/app/Libraries/Abdm/DirectAbdmConnector.php +++ b/app/Libraries/Abdm/DirectAbdmConnector.php @@ -167,6 +167,11 @@ class DirectAbdmConnector implements AbdmConnectorInterface throw new \RuntimeException('DirectAbdmConnector::abhaEnrolMobileVerifyOtp() not implemented.'); } + public function abhaLoginSelectAccount(array $payload): array + { + throw new \RuntimeException('DirectAbdmConnector::abhaLoginSelectAccount() not implemented.'); + } + public function abhaAddressSuggestions(array $payload): array { throw new \RuntimeException( diff --git a/app/Libraries/Abdm/DreamsoftConnector.php b/app/Libraries/Abdm/DreamsoftConnector.php index b847561..7a42146 100644 --- a/app/Libraries/Abdm/DreamsoftConnector.php +++ b/app/Libraries/Abdm/DreamsoftConnector.php @@ -182,6 +182,11 @@ class DreamsoftConnector implements AbdmConnectorInterface throw new \RuntimeException('ABHA enrolment mobile OTP verification requires the synchronous e-Atria Bridge connector.'); } + public function abhaLoginSelectAccount(array $payload): array + { + throw new \RuntimeException('ABHA account selection requires the synchronous e-Atria Bridge connector.'); + } + public function abhaAddressSuggestions(array $payload): array { throw new \RuntimeException( diff --git a/app/Libraries/Abdm/EAtriaBridgeConnector.php b/app/Libraries/Abdm/EAtriaBridgeConnector.php index 6dcf5b4..c142a57 100644 --- a/app/Libraries/Abdm/EAtriaBridgeConnector.php +++ b/app/Libraries/Abdm/EAtriaBridgeConnector.php @@ -999,6 +999,26 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface return $this->attachOfficialAbhaCard($result); } + public function abhaLoginSelectAccount(array $payload): array + { + $body = [ + 'txnId' => (string) ($payload['txnId'] ?? $payload['txn_id'] ?? ''), + 'token' => (string) ($payload['token'] ?? ''), + 'abha_number' => (string) ($payload['abha_number'] ?? $payload['abhaNumber'] ?? ''), + 'abha_address' => (string) ($payload['abha_address'] ?? $payload['abhaAddress'] ?? ''), + ]; + if ($this->hfrId !== '' && empty($body['hfr_id'])) { + $body['hfr_id'] = $this->hfrId; + } + + $result = $this->post('/v3/abha/login/select-account', $body); + if (empty($result['ok']) || (int) $result['ok'] !== 1) { + return $result; + } + + return $this->attachOfficialAbhaCard($result); + } + public function abhaAddressSuggestions(array $payload): array { $txnId = (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''); diff --git a/app/Views/partials/abha_mobile_modal.php b/app/Views/partials/abha_mobile_modal.php index bcc959d..e662fb1 100644 --- a/app/Views/partials/abha_mobile_modal.php +++ b/app/Views/partials/abha_mobile_modal.php @@ -7,6 +7,7 @@ * * 1. Mobile number * 2. OTP + * 2b. Account Selection (when multiple ABHA profiles are linked to mobile) * 3. Verified profile + official ABHA card * * Handing the profile to the patient-match modal stays the caller's job. @@ -32,7 +33,44 @@ .abha-mobile-modal .abha-profile-list dd { margin:0; text-align:right; font-weight:600; overflow-wrap:anywhere; } .abha-mobile-modal .abha-card-preview { min-height:230px; display:grid; place-items:center; border:1px solid #dce3ec; background:#f8fafc; } .abha-mobile-modal .abha-card-preview img { max-width:100%; max-height:360px; object-fit:contain; } - @media (max-width:575.98px) { .abha-mobile-modal .modal-body{padding:16px}.abha-mobile-step:not(:last-child)::after{width:22px}.abha-mobile-modal .abha-profile-list>div{grid-template-columns:110px minmax(0,1fr)} } + + /* Multi-Account Selection Card Styles */ + .abha-account-card { + border: 1px solid #dce3ec; + border-radius: 10px; + padding: 14px 18px; + transition: all 0.2s ease-in-out; + cursor: pointer; + background: #ffffff; + } + .abha-account-card:hover { + border-color: #356cf4; + background: #f4f8ff; + box-shadow: 0 4px 12px rgba(53, 108, 244, 0.12); + transform: translateY(-1px); + } + .abha-account-card.selected { + border-color: #356cf4; + background: #edf4ff; + box-shadow: 0 0 0 2px #356cf4; + } + .abha-account-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: #eef2f6; + display: grid; + place-items: center; + font-size: 1.25rem; + color: #356cf4; + flex-shrink: 0; + } + + @media (max-width:575.98px) { + .abha-mobile-modal .modal-body{padding:16px} + .abha-mobile-step:not(:last-child)::after{width:22px} + .abha-mobile-modal .abha-profile-list>div{grid-template-columns:110px minmax(0,1fr)} + }
+
@@ -60,6 +99,7 @@
Enter the mobile number registered with the patient's ABHA account to find and link it.
+

@@ -76,6 +116,21 @@
+ +
+
+
+
Select ABHA Profile
+ Multiple ABHA profiles found linked to this mobile number. Choose the patient's account: +
+ +
+
+ +
+
+ +
@@ -100,7 +155,10 @@
Official ABHA card was not returned by the Bridge.
-
+
+
+ +
@@ -115,8 +173,10 @@ window.AbhaMobileModal = (function () { var modal; var txnId = ''; + var currentToken = ''; var verifiedMobile = ''; var verifiedProfile = null; + var linkedAccounts = []; var onVerified = null; var resendTimer = null; var resendRemaining = 0; @@ -134,12 +194,20 @@ window.AbhaMobileModal = (function () { $('#abhaMobileAlert').html(message ? '
' + message + '
' : ''); } function showStep(step) { - $('#abhaMobileStep1,#abhaMobileStep2,#abhaMobileStep3').addClass('d-none'); - $('#abhaMobileStep' + step).removeClass('d-none'); - $('.abha-mobile-step').each(function () { - var itemStep = Number($(this).data('step')); - $(this).toggleClass('active', itemStep === step).toggleClass('done', itemStep < step); - }); + $('#abhaMobileStep1,#abhaMobileStep2,#abhaMobileStepAccounts,#abhaMobileStep3').addClass('d-none'); + if (step === 'accounts') { + $('#abhaMobileStepAccounts').removeClass('d-none'); + $('.abha-mobile-step').each(function () { + var itemStep = Number($(this).data('step')); + $(this).toggleClass('active', itemStep === 2).toggleClass('done', itemStep < 2); + }); + } else { + $('#abhaMobileStep' + step).removeClass('d-none'); + $('.abha-mobile-step').each(function () { + var itemStep = Number($(this).data('step')); + $(this).toggleClass('active', itemStep === step).toggleClass('done', itemStep < step); + }); + } alertBox('', ''); } function stopTimer() { @@ -180,6 +248,7 @@ window.AbhaMobileModal = (function () { if (mobile.length !== 10) { alertBox('warning', 'Enter a valid 10-digit mobile number.'); return; } verifiedMobile = mobile; + linkedAccounts = []; var button = $('#abhaMobileSendOtpBtn').prop('disabled', true).html('Sending'); $.post('', { mobile: mobile, find_abha: 1, '': csrf() }, function (response) { button.prop('disabled', false).html('Send OTP'); @@ -205,13 +274,111 @@ window.AbhaMobileModal = (function () { button.prop('disabled', false).html('Verify OTP'); if (!response || response.ok != 1) { alertBox('danger', apiMessage(response, 'OTP verification failed.')); return; } stopTimer(); - renderProfile(response); + + txnId = response.txn_id || txnId; + currentToken = response.token || ''; + linkedAccounts = Array.isArray(response.accounts) ? response.accounts : []; + + if (linkedAccounts.length > 1) { + renderAccountsList(linkedAccounts, response); + } else { + renderProfile(response); + } }, 'json').fail(function (xhr) { button.prop('disabled', false).html('Verify OTP'); alertBox('danger', apiMessage(xhr.responseJSON, 'OTP verification failed.')); }); } + function renderAccountsList(accounts, defaultResponse) { + $('#abhaAccountsCountHint').text('Found ' + accounts.length + ' ABHA accounts linked with mobile ' + maskMobile(verifiedMobile) + '. Select the patient:'); + var container = $('#abhaAccountsList').empty(); + + accounts.forEach(function (acc, index) { + var name = acc.name || 'ABHA Account #' + (index + 1); + var abhaNum = acc.ABHANumber || acc.abhaNumber || acc.abha_id || '-'; + var abhaAddr = acc.preferredAbhaAddress || acc.preferredAddress || acc.abhaAddress || acc.abha_address || '-'; + var gender = genderText(acc.gender); + var dob = acc.dob || acc.date_of_birth || '-'; + var status = acc.status || 'ACTIVE'; + + var iconClass = (gender === 'Female') ? 'bi-person-standing-dress text-danger' : 'bi-person-standing text-primary'; + if (gender === 'Other') iconClass = 'bi-person text-info'; + + var photoHtml = acc.profilePhoto + ? '' + : ''; + + var cardHtml = $( + '' + ); + + cardHtml.on('click', function () { + selectAccount(acc, cardHtml); + }); + + container.append(cardHtml); + }); + + showStep('accounts'); + } + + function selectAccount(account, cardElement) { + if (cardElement) { + $('.abha-account-card').removeClass('selected'); + cardElement.addClass('selected'); + cardElement.find('.select-acc-btn').prop('disabled', true).html('Loading'); + } + + var abhaNum = account.ABHANumber || account.abhaNumber || account.abha_id || ''; + var abhaAddr = account.preferredAbhaAddress || account.preferredAddress || account.abhaAddress || account.abha_address || ''; + + $.post('', { + txn_id: txnId, + token: currentToken, + abha_number: abhaNum, + abha_address: abhaAddr, + mobile: verifiedMobile, + find_abha: 1, + '': csrf() + }, function (response) { + if (cardElement) { + cardElement.find('.select-acc-btn').prop('disabled', false).html('Select Account'); + } + if (!response || response.ok != 1) { + alertBox('danger', apiMessage(response, 'Failed to select ABHA account.')); + return; + } + renderProfile(response); + }, 'json').fail(function (xhr) { + if (cardElement) { + cardElement.find('.select-acc-btn').prop('disabled', false).html('Select Account'); + } + alertBox('danger', apiMessage(xhr.responseJSON, 'Failed to select ABHA account.')); + }); + } + function renderProfile(profile) { verifiedProfile = profile; $('#abhaMobileProfileName').text(profile.name || '-'); @@ -237,6 +404,10 @@ window.AbhaMobileModal = (function () { var reason = profile.card_message ? escapeHtml(profile.card_message) : 'ABDM did not issue an official ABHA card for this session.'; $('#abhaMobileCardWrap').html('
' + reason + '
'); } + + // Show "Choose Different Account" button if multiple accounts exist + $('#abhaChangeAccountBtn').toggleClass('d-none', !(linkedAccounts && linkedAccounts.length > 1)); + showStep(3); } @@ -247,6 +418,12 @@ window.AbhaMobileModal = (function () { $('#abhaMobileResendBtn').on('click', sendOtp); $('#abhaMobileVerifyBtn').on('click', verifyOtp); $('#abhaMobileChangeBtn').on('click', function () { stopTimer(); showStep(1); $('#abhaMobileNumber').trigger('focus'); }); + $('#abhaAccountsBackBtn').on('click', function () { showStep(2); $('#abhaMobileOtp').trigger('focus'); }); + $('#abhaChangeAccountBtn').on('click', function () { + if (linkedAccounts && linkedAccounts.length > 1) { + showStep('accounts'); + } + }); $('#abhaMobileNumber').on('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); sendOtp(); } }); $('#abhaMobileOtp').on('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); verifyOtp(); } }); @@ -272,12 +449,15 @@ window.AbhaMobileModal = (function () { open: function (prefillMobile, callback) { onVerified = typeof prefillMobile === 'function' ? prefillMobile : callback; txnId = ''; + currentToken = ''; verifiedMobile = ''; verifiedProfile = null; + linkedAccounts = []; stopTimer(); $('#abhaMobileNumber').val(typeof prefillMobile === 'string' ? prefillMobile : ''); $('#abhaMobileOtp').val(''); $('#abhaMobilePhoto').addClass('d-none'); + $('#abhaChangeAccountBtn').addClass('d-none'); showStep(1); modal.show(); window.setTimeout(function () { $('#abhaMobileNumber').trigger('focus'); }, 300);