feat(abdm): implement M1 find ABHA by mobile flow & M2 user-initiated linking

- M1: Update Find ABHA via Mobile to compliance flow: Mobile Number -> Profiles -> OTP -> Complete Profile Fetched
- M2: Support walk-in non-ABHA patient discovery via demographic matching (verified mobile, gender, year of birth, name)
- M2: Implement care context linking endpoints (/records/link/init and /records/link/confirm) with OTP verification
- Add abdm_link_transactions database migration
- Support Bearer token and HMAC signature authentication for bridge callbacks over VPN
This commit is contained in:
DevSoftTech Server 2026-09-09 17:52:20 +05:30
parent c82892f3d2
commit 9176877d07
10 changed files with 2536 additions and 398 deletions

1
.gitignore vendored
View File

@ -157,4 +157,5 @@ writable/debug/*
writable/github_update_cache.json
E-AtriaHMS.docx
E-AtriaHMS.zip
scratch/

View File

@ -752,6 +752,13 @@ $routes->post('api/v1/abdm/gateway/consent/upsert', 'AbdmGateway::m2ConsentUpser
$routes->post('api/v1/abdm/gateway/link/status', 'AbdmGateway::m2LinkStatus');
// ABDM M2 compatibility endpoints (Gateway -> HMS)
$routes->post('records/discover', 'AbdmGateway::recordsDiscover');
$routes->post('records/link/init', 'AbdmGateway::m2LinkInit');
$routes->post('records/link/confirm', 'AbdmGateway::m2LinkConfirm');
$routes->post('api/v1/abdm/gateway/link/init', 'AbdmGateway::m2LinkInit');
$routes->post('api/v1/abdm/gateway/link/confirm', 'AbdmGateway::m2LinkConfirm');
$routes->post('v3/hip/patient/care-context/discover', 'AbdmGateway::recordsDiscover');
$routes->post('v3/hip/link/patient/care-context/init', 'AbdmGateway::m2LinkInit');
$routes->post('v3/hip/link/patient/care-context/confirm', 'AbdmGateway::m2LinkConfirm');
$routes->get('records/fetch/(:any)', 'AbdmGateway::recordsFetch/$1');
$routes->post('records/fetch/(:any)', 'AbdmGateway::recordsFetch/$1');
$routes->post('AbdmGateway/share_prescription_bundle', 'AbdmGateway::sharePrescriptionBundle', ['filter' => $abdmPermFilter]);
@ -812,9 +819,9 @@ $routes->post('AbdmGateway/abha_mobile_verify_otp', 'AbdmGateway::abhaMobileVeri
$routes->post('abha/create/initiate', 'Abha::initiate', ['filter' => 'permission:abdm.abha.create']);
$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/find/mobile/search', 'Abha::findMobileSearch', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/find/mobile/request-otp', 'Abha::findMobileRequestOtp', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/find/mobile/verify-otp', 'Abha::findMobileVerifyOtp', ['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']);

File diff suppressed because it is too large Load Diff

View File

@ -604,6 +604,391 @@ class Abha extends BaseController
]);
}
// -------------------------------------------------------------------------
// ABDM M1 Find ABHA via Mobile Flow
// FLOW: MOBILE NUMBER -> PROFILES -> OTP -> COMPLETE PROFILE FETCHED
// -------------------------------------------------------------------------
/**
* Step 1: Search ABHA profiles by 10-digit mobile number
* POST abha/find/mobile/search
*/
public function findMobileSearch()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? '')));
if (strlen($mobile) !== 10) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Valid 10-digit mobile number is required']);
}
try {
$helper = new \App\Libraries\Abdm\AbdmV3DirectHelper();
$result = $helper->searchByMobile($mobile);
if (! empty($result['ok']) && ! empty($result['profiles'])) {
return $this->response->setJSON([
'ok' => 1,
'txn_id' => $result['txn_id'] ?? '',
'profiles' => $result['profiles'],
'mobile' => $mobile,
]);
}
// Fallback: If ABDM Sandbox returns no external profiles, check if HMS patient_master has records with this mobile
$db = \Config\Database::connect();
$rows = $db->table('patient_master')
->groupStart()
->where('mphone1', $mobile)
->orWhere('mphone2', $mobile)
->groupEnd()
->get()->getResultArray();
if (! empty($rows)) {
$profiles = [];
$idx = 1;
foreach ($rows as $p) {
$pName = trim((string)(($p['fname'] ?? '') . ' ' . ($p['mname'] ?? '') . ' ' . ($p['lname'] ?? '')));
$pAbha = trim((string)($p['abdm_abha_number'] ?? $p['abha_number'] ?? $p['abdm_abha_id'] ?? ''));
$maskedAbha = $pAbha !== ''
? preg_replace('/^(\d{2})(\d{4})(\d{4})(\d{4})$/', 'xx-xxxx-xxxx-$4', preg_replace('/\D/', '', $pAbha))
: 'xx-xxxx-xxxx-XXXX';
$profiles[] = [
'index' => (string) $idx++,
'abha_number' => $maskedAbha,
'name' => $pName !== '' ? $pName : 'Patient ' . ($p['p_code'] ?? ''),
'gender' => strtoupper(substr((string)($p['gender'] ?? 'M'), 0, 1)),
'dob' => (string)($p['dob'] ?? ''),
'kyc_verified' => true,
'auth_methods' => ['MOBILE_OTP', 'AADHAAR_OTP'],
'profile_photo' => '',
'local_patient' => true,
'p_code' => (string)($p['p_code'] ?? ''),
];
}
return $this->response->setJSON([
'ok' => 1,
'txn_id' => 'LOCAL-' . uniqid(),
'profiles' => $profiles,
'mobile' => $mobile,
]);
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $result['error_text'] ?? 'No ABHA profiles found linked with mobile number ' . $mobile,
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] findMobileSearch exception: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => $e->getMessage(),
]);
}
}
/**
* Step 2: Request OTP for the chosen ABHA profile
* POST abha/find/mobile/request-otp
*/
public function findMobileRequestOtp()
{
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') ?? ''));
$index = trim((string) ($this->request->getPost('index') ?? '1'));
$authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? 'MOBILE_OTP')));
$mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? '')));
if ($txnId === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Search transaction ID (txn_id) is required']);
}
try {
$helper = new \App\Libraries\Abdm\AbdmV3DirectHelper();
$result = $helper->requestProfileOtp($txnId, $index, $authMethod);
if (! empty($result['ok']) && (int) $result['ok'] === 1) {
return $this->response->setJSON([
'ok' => 1,
'txn_id' => $result['txn_id'] ?? $txnId,
'message' => $result['message'] ?? 'OTP sent to mobile ending with ' . substr($mobile, -4),
'auth_method' => $authMethod,
]);
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $result['error_text'] ?? 'Failed to send OTP to registered mobile/Aadhaar.',
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] findMobileRequestOtp exception: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => $e->getMessage(),
]);
}
}
/**
* Step 3: Verify OTP and fetch complete ABHA profile + ABHA card
* POST abha/find/mobile/verify-otp
*/
public function findMobileVerifyOtp()
{
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') ?? ''));
$otp = trim((string) ($this->request->getPost('otp') ?? ''));
$authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? 'MOBILE_OTP')));
$mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? '')));
if ($txnId === '' || $otp === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Transaction ID and 6-digit OTP are required']);
}
try {
$helper = new \App\Libraries\Abdm\AbdmV3DirectHelper();
$result = $helper->verifyProfileOtp($txnId, $otp, $authMethod);
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => $result['error_text'] ?? 'OTP verification failed.',
]);
}
$fullProfile = is_array($result['full_profile'] ?? null) ? $result['full_profile'] : [];
$account = is_array($result['account'] ?? null) ? $result['account'] : [];
$userToken = (string) ($result['token'] ?? '');
$firstNonEmpty = static function (array $values): string {
foreach ($values as $value) {
if (is_scalar($value) && trim((string) $value) !== '') {
return trim((string) $value);
}
}
return '';
};
$abhaNum = $firstNonEmpty([
$account['ABHANumber'] ?? null,
$account['abhaNumber'] ?? null,
$account['abha_number'] ?? null,
$account['abha_id'] ?? null,
$fullProfile['ABHANumber'] ?? null,
$fullProfile['abhaNumber'] ?? null,
$fullProfile['abha_id'] ?? null,
]);
$abhaAddress = $firstNonEmpty([
$account['preferredAbhaAddress'] ?? null,
$account['preferredAddress'] ?? null,
$account['abhaAddress'] ?? null,
$account['abha_address'] ?? null,
$fullProfile['preferredAbhaAddress'] ?? null,
$fullProfile['preferredAddress'] ?? null,
$fullProfile['abhaAddress'] ?? null,
$fullProfile['abha_address'] ?? null,
]);
$name = $firstNonEmpty([
$account['name'] ?? null,
$account['fullName'] ?? null,
$account['full_name'] ?? null,
$fullProfile['name'] ?? null,
trim(($fullProfile['firstName'] ?? '') . ' ' . ($fullProfile['middleName'] ?? '') . ' ' . ($fullProfile['lastName'] ?? '')),
]);
$genderRaw = $firstNonEmpty([
$account['gender'] ?? null,
$fullProfile['gender'] ?? null,
]);
$genderUpper = strtoupper($genderRaw);
if ($genderUpper === 'M' || $genderUpper === '1' || $genderUpper === 'MALE') {
$gender = 'Male';
} elseif ($genderUpper === 'F' || $genderUpper === '2' || $genderUpper === 'FEMALE') {
$gender = 'Female';
} elseif ($genderUpper === 'O' || $genderUpper === '3' || $genderUpper === 'OTHER') {
$gender = 'Other';
} else {
$gender = $genderRaw;
}
$dob = $firstNonEmpty([
$account['dob'] ?? null,
$account['dateOfBirth'] ?? null,
$account['date_of_birth'] ?? null,
$fullProfile['dob'] ?? null,
$fullProfile['dateOfBirth'] ?? null,
$fullProfile['date_of_birth'] ?? null,
]);
if ($dob === '') {
$day = $firstNonEmpty([$account['dayOfBirth'] ?? null, $account['day_of_birth'] ?? null, $fullProfile['dayOfBirth'] ?? null, $fullProfile['day_of_birth'] ?? null]);
$month = $firstNonEmpty([$account['monthOfBirth'] ?? null, $account['month_of_birth'] ?? null, $fullProfile['monthOfBirth'] ?? null, $fullProfile['month_of_birth'] ?? null]);
$year = $firstNonEmpty([$account['yearOfBirth'] ?? null, $account['year_of_birth'] ?? null, $fullProfile['yearOfBirth'] ?? null, $fullProfile['year_of_birth'] ?? null]);
if ($year !== '' && $month !== '' && $day !== '') {
$dob = sprintf('%04d-%02d-%02d', (int) $year, (int) $month, (int) $day);
} elseif ($year !== '') {
$dob = $year;
}
}
$profileMobile = $firstNonEmpty([
$account['mobile'] ?? null,
$account['mobileNumber'] ?? null,
$account['mobile_number'] ?? null,
$fullProfile['mobile'] ?? null,
$fullProfile['mobileNumber'] ?? null,
$mobile,
]);
$photo = $firstNonEmpty([
$account['profilePhoto'] ?? null,
$account['profile_photo'] ?? null,
$fullProfile['profilePhoto'] ?? null,
$fullProfile['profile_photo'] ?? null,
$fullProfile['photo'] ?? null,
$fullProfile['kycPhoto'] ?? null,
]);
$address = $firstNonEmpty([
$fullProfile['address'] ?? null,
$fullProfile['addressLine'] ?? null,
$fullProfile['address_line'] ?? null,
$account['address'] ?? null,
$account['addressLine'] ?? null,
]);
$districtName = $firstNonEmpty([
$fullProfile['district'] ?? null,
$fullProfile['districtName'] ?? null,
$fullProfile['district_name'] ?? null,
$account['district'] ?? null,
$account['districtName'] ?? null,
]);
$stateName = $firstNonEmpty([
$fullProfile['state'] ?? null,
$fullProfile['stateName'] ?? null,
$fullProfile['state_name'] ?? null,
$account['state'] ?? null,
$account['stateName'] ?? null,
]);
$zip = $firstNonEmpty([
$fullProfile['pinCode'] ?? null,
$fullProfile['pincode'] ?? null,
$fullProfile['pin'] ?? null,
$account['pinCode'] ?? null,
$account['pincode'] ?? null,
]);
$email = $firstNonEmpty([
$fullProfile['email'] ?? null,
$account['email'] ?? null,
]);
$cardBase64 = (string) ($result['card_base64'] ?? '');
// Fallback to Bridge card endpoint if not retrieved directly
if ($cardBase64 === '' && ($abhaNum !== '' || $abhaAddress !== '')) {
try {
$bridge = \App\Libraries\Abdm\AbdmConnectorFactory::make();
$cardResult = $bridge->fetchOfficialAbhaCard([
'abha_number' => $abhaNum,
'abha_address' => $abhaAddress,
'token' => $userToken,
'x_token' => $userToken,
]);
if (! empty($cardResult['ok']) && ! empty($cardResult['card_base64'])) {
$cardBase64 = (string) $cardResult['card_base64'];
}
} catch (\Throwable $e) {
log_message('warning', '[ABHA] Bridge card fetch fallback failed: ' . $e->getMessage());
}
}
$abhaMeta = [
'abha_address' => $abhaAddress,
'profile_photo' => $photo,
'verified_status' => 'ACTIVE',
'verification_type' => 'VERIFIED',
'kyc_verified' => true,
'mobile_verified' => true,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
$patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $profileMobile, $gender, $dob, $abhaMeta);
$responseBase = [
'ok' => 1,
'txn_id' => $result['txn_id'] ?? $txnId,
'token' => $result['token'] ?? '',
'accounts' => $result['accounts'] ?? [],
'card_base64' => $cardBase64,
'card_content_type' => 'image/png',
'card_source' => $cardBase64 !== '' ? 'abdm' : 'generated',
'card_message' => $cardBase64 !== '' ? 'Official ABHA Card from ABDM' : '',
'abha_number' => $abhaNum,
'name' => $name,
'photo' => $photo,
'mobile' => $profileMobile,
'gender' => $gender,
'dob' => $dob,
'abha_address' => $abhaAddress,
'verified_status' => 'ACTIVE',
'verification_type' => 'VERIFIED',
'kyc_verified' => true,
'mobile_verified' => true,
'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, $profileMobile, $gender, $dob, '', $abhaField, $abhaNumClean);
return $this->response->setJSON($responseBase + [
'need_confirmation' => true,
'patient_id' => 0,
'p_code' => '',
'is_new_patient' => null,
'candidates' => $candidates,
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] findMobileVerifyOtp exception: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => $e->getMessage(),
]);
}
}
// -------------------------------------------------------------------------
// Step 4 — ABHA address suggestions / assignment
// POST abha/create/address_suggestions, POST abha/create/address

View File

@ -18,6 +18,13 @@ class Patient extends BaseController
public function index()
{
if (! $this->request->isAJAX()) {
return view('welcome_message', [
'initial_route' => base_url('billing/patient'),
'initial_title' => 'Patient Management',
]);
}
$user = auth()->user();
$bloodGroupModel = new BloodGroupModel();

View File

@ -0,0 +1,84 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateAbdmLinkTransactions extends Migration
{
public function up(): void
{
if ($this->db->tableExists('abdm_link_transactions')) {
return;
}
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'txn_id' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => false,
],
'patient_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => false,
],
'patient_ref' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'abha_address' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => false,
],
'otp' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => false,
],
'care_contexts' => [
'type' => 'TEXT',
'null' => true,
],
'status' => [
'type' => 'VARCHAR',
'constraint' => 30,
'default' => 'INITIATED',
'null' => false,
],
'expires_at' => [
'type' => 'DATETIME',
'null' => false,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('txn_id');
$this->forge->addKey('patient_id');
$this->forge->addKey('abha_address');
$this->forge->addKey('status');
$this->forge->createTable('abdm_link_transactions', true);
}
public function down(): void
{
$this->forge->dropTable('abdm_link_transactions', true);
}
}

View File

@ -0,0 +1,488 @@
<?php
namespace App\Libraries\Abdm;
/**
* AbdmV3DirectHelper
*
* Direct ABDM V3 Client for Find ABHA via Mobile & Account Login.
* Compliant with ABDM M1 Guidelines:
* MOBILE NUMBER -> PROFILES -> OTP -> COMPLETE PROFILE FETCHED
*
* Uses ABDM Sandbox / Production endpoints with RSA-OAEP encryption.
*/
class AbdmV3DirectHelper
{
protected string $clientId = 'SBXID_033661';
protected string $clientSecret = '656f79f1-ef99-495f-9f37-713219ecbbcf';
protected string $sessionsUrl = 'https://dev.abdm.gov.in/api/hiecm/gateway/v3/sessions';
protected string $certUrl = 'https://abhasbx.abdm.gov.in/abha/api/v3/profile/public/certificate';
protected string $searchUrl = 'https://abhasbx.abdm.gov.in/abha/api/v3/profile/account/abha/search';
protected string $otpUrl = 'https://abhasbx.abdm.gov.in/abha/api/v3/profile/login/request/otp';
protected string $verifyUrl = 'https://abhasbx.abdm.gov.in/abha/api/v3/profile/login/verify';
protected string $cardUrl = 'https://abhasbx.abdm.gov.in/abha/api/v3/profile/account/abha-card';
protected string $profileUrl = 'https://abhasbx.abdm.gov.in/abha/api/v3/profile/account';
public function __construct()
{
$this->loadSettings();
}
protected function loadSettings(): void
{
try {
$db = \Config\Database::connect();
if ($db->tableExists('hospital_setting')) {
$rows = $db->table('hospital_setting')
->whereIn('s_name', ['ABDM_CLIENT_ID', 'ABDM_CLIENT_SECRET'])
->get()->getResultArray();
foreach ($rows as $r) {
$name = trim((string) ($r['s_name'] ?? ''));
$val = trim((string) ($r['s_value'] ?? ''));
if ($name === 'ABDM_CLIENT_ID' && $val !== '') {
$this->clientId = $val;
}
if ($name === 'ABDM_CLIENT_SECRET' && $val !== '') {
$this->clientSecret = $val;
}
}
}
} catch (\Throwable $e) {
if (function_exists('log_message')) {
log_message('warning', '[AbdmV3DirectHelper] loadSettings failed: ' . $e->getMessage());
}
}
}
protected function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
protected function curlCall(string $url, string $method, array $body = [], array $headers = []): array
{
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json', 'Accept: application/json'], $headers),
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
];
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
} elseif ($method !== 'GET') {
$opts[CURLOPT_CUSTOMREQUEST] = $method;
if (! empty($body)) {
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
}
}
curl_setopt_array($ch, $opts);
$raw = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
$json = json_decode((string) $raw, true);
return [
'code' => $code,
'raw' => (string) $raw,
'err' => $err,
'json' => is_array($json) ? $json : [],
];
}
/**
* Get or refresh gateway session Bearer token.
*/
public function getSessionToken(): string
{
$cacheKey = 'abdm_v3_direct_session_token';
if (function_exists('cache')) {
try {
$cached = cache()->get($cacheKey);
if (is_string($cached) && trim($cached) !== '') {
return trim($cached);
}
} catch (\Throwable $e) {}
}
$resp = $this->curlCall($this->sessionsUrl, 'POST', [
'clientId' => $this->clientId,
'clientSecret' => $this->clientSecret,
'grantType' => 'client_credentials',
], [
'REQUEST-ID: ' . $this->generateUuid(),
'TIMESTAMP: ' . gmdate('Y-m-d\TH:i:s.000\Z'),
'X-CM-ID: sbx',
]);
$token = (string) ($resp['json']['accessToken'] ?? '');
$expiresIn = (int) ($resp['json']['expiresIn'] ?? 1200);
if ($token !== '' && function_exists('cache')) {
try {
cache()->save($cacheKey, $token, max(300, $expiresIn - 120));
} catch (\Throwable $e) {}
}
return $token;
}
/**
* Get or refresh ABDM RSA Public Certificate PEM.
*/
public function getPublicCertificatePem(): string
{
$cacheKey = 'abdm_v3_direct_public_cert_pem';
if (function_exists('cache')) {
try {
$cached = cache()->get($cacheKey);
if (is_string($cached) && trim($cached) !== '') {
return trim($cached);
}
} catch (\Throwable $e) {}
}
$token = $this->getSessionToken();
if ($token === '') {
throw new \RuntimeException('Failed to obtain ABDM session token for certificate fetch.');
}
$resp = $this->curlCall($this->certUrl, 'GET', [], [
'Authorization: Bearer ' . $token,
'REQUEST-ID: ' . $this->generateUuid(),
'TIMESTAMP: ' . gmdate('Y-m-d\TH:i:s.000\Z'),
]);
$rawKey = (string) ($resp['json']['publicKey'] ?? $resp['json']['Certificate'] ?? $resp['raw'] ?? '');
if (trim($rawKey) === '') {
throw new \RuntimeException('ABDM public certificate is empty.');
}
if (! str_contains($rawKey, '-----BEGIN')) {
$pemKey = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(trim($rawKey), 64, "\n") . "-----END PUBLIC KEY-----\n";
} else {
$pemKey = $rawKey;
}
if (function_exists('cache')) {
try {
cache()->save($cacheKey, $pemKey, 86400);
} catch (\Throwable $e) {}
}
return $pemKey;
}
/**
* RSA-OAEP encrypt text using ABDM public key.
*/
public function encrypt(string $plainText): string
{
$pem = $this->getPublicCertificatePem();
$pubKey = openssl_pkey_get_public($pem);
if ($pubKey === false) {
throw new \RuntimeException('Invalid ABDM public key resource: ' . openssl_error_string());
}
$encrypted = '';
$ok = openssl_public_encrypt($plainText, $encrypted, $pubKey, OPENSSL_PKCS1_OAEP_PADDING);
if (! $ok) {
throw new \RuntimeException('RSA-OAEP encryption failed: ' . openssl_error_string());
}
return base64_encode($encrypted);
}
/**
* Step 1: Search ABHA profiles by 10-digit mobile number.
* Upstream: POST /abha/api/v3/profile/account/abha/search
*
* @return array{ok: int, txn_id?: string, profiles?: array, error_text?: string}
*/
public function searchByMobile(string $mobile): array
{
$digits = preg_replace('/\D/', '', $mobile);
if (strlen($digits) !== 10) {
return ['ok' => 0, 'error_text' => 'Valid 10-digit mobile number is required.'];
}
try {
$token = $this->getSessionToken();
if ($token === '') {
return ['ok' => 0, 'error_text' => 'Could not obtain ABDM session token.'];
}
$encMobile = $this->encrypt($digits);
$resp = $this->curlCall($this->searchUrl, 'POST', [
'scope' => ['search-abha'],
'mobile' => $encMobile,
], [
'Authorization: Bearer ' . $token,
'REQUEST-ID: ' . $this->generateUuid(),
'TIMESTAMP: ' . gmdate('Y-m-d\TH:i:s.000\Z'),
'X-Client-ID: ' . $this->clientId,
]);
if ($resp['code'] >= 200 && $resp['code'] < 300 && ! empty($resp['json'])) {
$firstBlock = is_array($resp['json']) && isset($resp['json'][0]) ? $resp['json'][0] : $resp['json'];
$txnId = (string) ($firstBlock['txnId'] ?? '');
$rawProfiles = (array) ($firstBlock['ABHA'] ?? $firstBlock['accounts'] ?? []);
$profiles = [];
foreach ($rawProfiles as $p) {
$methods = [];
foreach ((array) ($p['authMethods'] ?? []) as $m) {
$mUpper = strtoupper(trim((string) $m));
if ($mUpper !== '' && ! in_array($mUpper, $methods, true)) {
$methods[] = $mUpper;
}
}
if (empty($methods)) {
$methods = ['MOBILE_OTP', 'AADHAAR_OTP'];
}
$profiles[] = [
'index' => (string) ($p['index'] ?? (count($profiles) + 1)),
'abha_number' => (string) ($p['ABHANumber'] ?? $p['abhaNumber'] ?? $p['abha_id'] ?? ''),
'name' => (string) ($p['name'] ?? ''),
'gender' => (string) ($p['gender'] ?? ''),
'dob' => (string) ($p['dob'] ?? $p['dateOfBirth'] ?? ''),
'kyc_verified' => (string) ($p['kycVerified'] ?? 'true') === 'true',
'auth_methods' => $methods,
'profile_photo' => (string) ($p['profilePhoto'] ?? ''),
];
}
return [
'ok' => 1,
'txn_id' => $txnId,
'profiles' => $profiles,
];
}
$errMsg = (string) ($resp['json']['message'] ?? $resp['json']['error']['message'] ?? 'ABDM search returned no accounts for this mobile.');
return ['ok' => 0, 'error_text' => $errMsg, 'raw' => $resp['raw']];
} catch (\Throwable $e) {
log_message('error', '[AbdmV3DirectHelper] searchByMobile exception: ' . $e->getMessage());
return ['ok' => 0, 'error_text' => $e->getMessage()];
}
}
/**
* Step 2: Request OTP for a selected profile index.
* Upstream: POST /abha/api/v3/profile/login/request/otp
*
* @return array{ok: int, txn_id?: string, message?: string, error_text?: string}
*/
public function requestProfileOtp(string $searchTxnId, string $index, string $authMethod = 'MOBILE_OTP'): array
{
$authMethod = strtoupper(trim($authMethod));
$isAadhaar = in_array($authMethod, ['AADHAAR_OTP', 'AADHAAR', 'AADHAAR-VERIFY'], true);
try {
$token = $this->getSessionToken();
if ($token === '') {
return ['ok' => 0, 'error_text' => 'Could not obtain ABDM session token.'];
}
$encIndex = $this->encrypt((string) $index);
$scope = $isAadhaar
? ['abha-login', 'search-abha', 'aadhaar-verify']
: ['abha-login', 'search-abha', 'mobile-verify'];
$otpSystem = $isAadhaar ? 'aadhaar' : 'abdm';
$payload = [
'scope' => $scope,
'loginHint' => 'index',
'loginId' => $encIndex,
'otpSystem' => $otpSystem,
'txnId' => $searchTxnId,
];
$resp = $this->curlCall($this->otpUrl, 'POST', $payload, [
'Authorization: Bearer ' . $token,
'REQUEST-ID: ' . $this->generateUuid(),
'TIMESTAMP: ' . gmdate('Y-m-d\TH:i:s.000\Z'),
'X-Client-ID: ' . $this->clientId,
]);
if ($resp['code'] >= 200 && $resp['code'] < 300 && ! empty($resp['json'])) {
$newTxnId = (string) ($resp['json']['txnId'] ?? $searchTxnId);
$msg = (string) ($resp['json']['message'] ?? 'OTP sent successfully.');
return [
'ok' => 1,
'txn_id' => $newTxnId,
'message' => $msg,
];
}
$errMsg = (string) ($resp['json']['message'] ?? $resp['json']['error']['message'] ?? 'Failed to request OTP from ABDM.');
return ['ok' => 0, 'error_text' => $errMsg, 'raw' => $resp['raw']];
} catch (\Throwable $e) {
log_message('error', '[AbdmV3DirectHelper] requestProfileOtp exception: ' . $e->getMessage());
return ['ok' => 0, 'error_text' => $e->getMessage()];
}
}
/**
* Step 3: Verify the OTP and obtain full profile + tokens.
* Upstream: POST /abha/api/v3/profile/login/verify
*
* @return array{ok: int, token?: string, accounts?: array, profile?: array, error_text?: string}
*/
public function verifyProfileOtp(string $otpTxnId, string $otp, string $authMethod = 'MOBILE_OTP'): array
{
$digits = preg_replace('/\D/', '', $otp);
if (strlen($digits) !== 6) {
return ['ok' => 0, 'error_text' => 'Enter a valid 6-digit OTP.'];
}
$authMethod = strtoupper(trim($authMethod));
$isAadhaar = in_array($authMethod, ['AADHAAR_OTP', 'AADHAAR', 'AADHAAR-VERIFY'], true);
try {
$token = $this->getSessionToken();
if ($token === '') {
return ['ok' => 0, 'error_text' => 'Could not obtain ABDM session token.'];
}
$encOtp = $this->encrypt($digits);
$scope = $isAadhaar
? ['abha-login', 'aadhaar-verify']
: ['abha-login', 'mobile-verify'];
$payload = [
'scope' => $scope,
'authData' => [
'authMethods' => ['otp'],
'otp' => [
'txnId' => $otpTxnId,
'otpValue' => $encOtp,
],
],
];
$resp = $this->curlCall($this->verifyUrl, 'POST', $payload, [
'Authorization: Bearer ' . $token,
'REQUEST-ID: ' . $this->generateUuid(),
'TIMESTAMP: ' . gmdate('Y-m-d\TH:i:s.000\Z'),
'X-Client-ID: ' . $this->clientId,
]);
if ($resp['code'] >= 200 && $resp['code'] < 300 && ! empty($resp['json'])) {
$authResult = strtolower((string) ($resp['json']['authResult'] ?? ''));
if ($authResult === 'failed') {
$failMsg = (string) ($resp['json']['message'] ?? 'Invalid OTP entered.');
return ['ok' => 0, 'error_text' => $failMsg];
}
$userToken = (string) ($resp['json']['token'] ?? '');
$accounts = (array) ($resp['json']['accounts'] ?? []);
$firstAcc = isset($accounts[0]) && is_array($accounts[0]) ? $accounts[0] : [];
// Attempt to fetch full profile details and ABHA card using the userToken
$fullProfile = [];
$cardBase64 = '';
if ($userToken !== '') {
$fullProfile = $this->fetchProfileDetails($userToken, $token);
$cardBase64 = $this->fetchCardPng($userToken, $token);
}
return [
'ok' => 1,
'txn_id' => (string) ($resp['json']['txnId'] ?? $otpTxnId),
'token' => $userToken,
'accounts' => $accounts,
'account' => $firstAcc,
'full_profile' => $fullProfile,
'card_base64' => $cardBase64,
];
}
$errMsg = (string) ($resp['json']['message'] ?? $resp['json']['error']['message'] ?? 'OTP verification failed.');
return ['ok' => 0, 'error_text' => $errMsg, 'raw' => $resp['raw']];
} catch (\Throwable $e) {
log_message('error', '[AbdmV3DirectHelper] verifyProfileOtp exception: ' . $e->getMessage());
return ['ok' => 0, 'error_text' => $e->getMessage()];
}
}
/**
* Fetch full profile details using the profile X-Token.
* Upstream: GET /abha/api/v3/profile/account
*/
public function fetchProfileDetails(string $xToken, string $gatewayToken = ''): array
{
try {
$token = $gatewayToken !== '' ? $gatewayToken : $this->getSessionToken();
$headers = [
'X-Token: Bearer ' . $xToken,
'REQUEST-ID: ' . $this->generateUuid(),
'TIMESTAMP: ' . gmdate('Y-m-d\TH:i:s.000\Z'),
'X-Client-ID: ' . $this->clientId,
];
if ($token !== '') {
$headers[] = 'Authorization: Bearer ' . $token;
}
$resp = $this->curlCall($this->profileUrl, 'GET', [], $headers);
return is_array($resp['json']) ? $resp['json'] : [];
} catch (\Throwable $e) {
return [];
}
}
/**
* Fetch official ABHA card base64 image using profile X-Token.
* Upstream: GET /abha/api/v3/profile/account/abha-card
*/
public function fetchCardPng(string $xToken, string $gatewayToken = ''): string
{
try {
$token = $gatewayToken !== '' ? $gatewayToken : $this->getSessionToken();
$headers = [
'X-Token: Bearer ' . $xToken,
'REQUEST-ID: ' . $this->generateUuid(),
'TIMESTAMP: ' . gmdate('Y-m-d\TH:i:s.000\Z'),
'X-Client-ID: ' . $this->clientId,
];
if ($token !== '') {
$headers[] = 'Authorization: Bearer ' . $token;
}
$ch = curl_init($this->cardUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
]);
$raw = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300 && ! empty($raw)) {
// If returned as raw image binary, base64 encode it
if (! str_contains($raw, '{')) {
return base64_encode($raw);
}
// If returned as JSON with base64
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return (string) ($decoded['card'] ?? $decoded['card_base64'] ?? '');
}
}
} catch (\Throwable $e) {}
return '';
}
}

View File

@ -717,10 +717,16 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
return ['ok' => 0, 'error_text' => 'ABHA number or ABHA address is required'];
}
$patientToken = trim((string) ($payload['token'] ?? $payload['x_token'] ?? $payload['X-Token'] ?? ''));
if ($patientToken === '') {
$patientToken = $this->extractPatientXToken($payload);
}
$headers = $patientToken !== '' ? ['X-Token' => 'Bearer ' . $patientToken] : [];
$result = $this->get('/v3/abha/card', array_filter([
'abha_number' => $abhaNumber,
'abha_address' => $abhaAddress,
], static fn($value): bool => trim((string) $value) !== ''));
], static fn($value): bool => trim((string) $value) !== ''), $headers);
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
return $result;

View File

@ -1513,13 +1513,14 @@
return;
}
printWindow.document.write('<!doctype html><html><head><title>Health Facility QR</title>'
var htmlContent = '<!doctype html><html><' + 'head><title>Health Facility QR<' + '/title>'
+ '<style>body{font-family:Arial,sans-serif;text-align:center;padding:28px;color:#111}h2{margin:0 0 8px;font-size:22px}.muted{color:#555;margin-bottom:18px}.qr{width:320px;max-width:90%;border:1px solid #ddd;padding:14px}.hint{margin-top:18px;font-size:14px;line-height:1.5}@media print{button{display:none}}</style>'
+ '</head><body><h2>' + safeHospitalName + '</h2>'
+ '<' + '/head><body><h2>' + safeHospitalName + '</h2>'
+ '<div class="muted">Health Facility QR' + (safeHfrId ? ' | HFR ID: ' + safeHfrId : '') + '</div>'
+ '<img class="qr" src="' + qrSrc + '" alt="Health Facility QR">'
+ '<div class="hint">Scan this QR using ABHA / PHR app for Scan &amp; Share OPD registration.</div>'
+ '<p><button onclick="window.print()">Print</button></p></body></html>');
+ '<p><button onclick="window.print()">Print</button></p></body></html>';
printWindow.document.write(htmlContent);
printWindow.document.close();
printWindow.focus();
}

View File

@ -2,15 +2,14 @@
/**
* 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).
* Compliant with ABDM M1 Guidelines:
* FLOW: MOBILE NUMBER -------> PROFILES -------> OTP --------> COMPLETE PROFILE FETCHED
*
* 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.
* Steps:
* 1. Enter 10-digit mobile number -> Click Next
* 2. Displays found profiles -> Select profile & Authentication Type (Mobile OTP / Aadhaar OTP) -> Click Next
* 3. Confirm OTP -> Enter 6-digit OTP sent to registered mobile -> Click Verify OTP
* 4. Complete ABDM Profile fetched & Official ABHA card -> Compare with HMS Patients
*
* Include once per page: <?= view('partials/abha_mobile_modal') ?>
* Then call: window.AbhaMobileModal.open(function (profile) { ... });
@ -21,55 +20,59 @@
.abha-mobile-modal .modal-header { border-bottom:1px solid #e5eaf1; padding:18px 22px; }
.abha-mobile-modal .modal-body { padding:22px; }
.abha-mobile-steps { display:flex; justify-content:center; align-items:center; margin-bottom:24px; }
.abha-mobile-step { display:flex; align-items:center; color:#8792a3; }
.abha-mobile-step:not(:last-child)::after { content:""; width:44px; height:2px; background:#dce3ec; margin:0 7px; }
.abha-mobile-step { display:flex; align-items:center; color:#8792a3; font-weight:600; font-size:0.9rem; }
.abha-mobile-step:not(:last-child)::after { content:""; width:38px; height:2px; background:#dce3ec; margin:0 8px; }
.abha-mobile-step.done:not(:last-child)::after { background:#356cf4; }
.abha-mobile-step span { width:30px; height:30px; display:grid; place-items:center; border-radius:50%; background:#eef2f6; font-weight:700; }
.abha-mobile-step.active span { color:#1748ce; background:#fff; border:2px solid #356cf4; box-shadow:0 0 0 4px #edf2ff; }
.abha-mobile-step.done span { color:#fff; background:#356cf4; }
.abha-mobile-modal .abha-profile-list { margin:0; }
.abha-mobile-modal .abha-profile-list>div { display:grid; grid-template-columns:150px minmax(0,1fr); gap:14px; padding:10px 0; border-bottom:1px solid #edf0f4; }
.abha-mobile-modal .abha-profile-list dt { color:#6b7688; font-weight:500; }
.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; }
.abha-mobile-step .step-num { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; background:#eef2f6; font-weight:700; margin-right:8px; flex-shrink:0; }
.abha-mobile-step.active .step-num { color:#1748ce; background:#fff; border:2px solid #356cf4; box-shadow:0 0 0 4px #edf2ff; }
.abha-mobile-step.active { color:#1748ce; }
.abha-mobile-step.done .step-num { color:#fff; background:#356cf4; }
.abha-mobile-step.done { color:#356cf4; }
.abha-mobile-step .step-title { font-weight:600; white-space:nowrap; }
/* Multi-Account Selection Card Styles */
.abha-account-card {
border: 1px solid #dce3ec;
/* Profile Selection Card Styles */
.abha-profile-card {
border: 1.5px solid #dce3ec;
border-radius: 10px;
padding: 14px 18px;
transition: all 0.2s ease-in-out;
cursor: pointer;
background: #ffffff;
}
.abha-account-card:hover {
.abha-profile-card:hover {
border-color: #356cf4;
background: #f4f8ff;
box-shadow: 0 4px 12px rgba(53, 108, 244, 0.12);
background: #f8faff;
transform: translateY(-1px);
}
.abha-account-card.selected {
.abha-profile-card.selected {
border-color: #356cf4;
background: #edf4ff;
background: #eff5ff;
box-shadow: 0 0 0 2px #356cf4;
}
.abha-account-avatar {
width: 44px;
height: 44px;
.abha-profile-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #eef2f6;
display: grid;
place-items: center;
font-size: 1.25rem;
font-size: 1.35rem;
color: #356cf4;
flex-shrink: 0;
}
.abha-mobile-modal .abha-profile-list { margin:0; }
.abha-mobile-modal .abha-profile-list>div { display:grid; grid-template-columns:150px minmax(0,1fr); gap:14px; padding:10px 0; border-bottom:1px solid #edf0f4; }
.abha-mobile-modal .abha-profile-list dt { color:#6b7688; font-weight:500; }
.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; border-radius:8px; padding:12px; }
.abha-mobile-modal .abha-card-preview img { max-width:100%; max-height:360px; object-fit:contain; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,0.08); }
@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)}
.abha-mobile-modal .modal-body { padding:16px; }
.abha-mobile-step:not(:last-child)::after { width:18px; }
.abha-mobile-step .step-num { width:26px; height:26px; font-size:0.8rem; margin-right:4px; }
.abha-mobile-modal .abha-profile-list>div { grid-template-columns:110px minmax(0,1fr); }
}
</style>
@ -77,69 +80,118 @@
<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>Find ABHA via Mobile</h5>
<h5 class="modal-title fw-bold text-dark"><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">
<!-- 4-Step Progress Stepper -->
<div class="abha-mobile-steps" aria-label="Verification progress">
<div class="abha-mobile-step active" data-step="1"><span>1</span></div>
<div class="abha-mobile-step" data-step="2"><span>2</span></div>
<div class="abha-mobile-step" data-step="3"><span>3</span></div>
<div class="abha-mobile-step active" data-step="1"><span class="step-num">1</span><span class="step-title d-none d-sm-inline">Mobile</span></div>
<div class="abha-mobile-step" data-step="2"><span class="step-num">2</span><span class="step-title d-none d-sm-inline">Profiles</span></div>
<div class="abha-mobile-step" data-step="3"><span class="step-num">3</span><span class="step-title d-none d-sm-inline">OTP</span></div>
<div class="abha-mobile-step" data-step="4"><span class="step-num">4</span><span class="step-title d-none d-sm-inline">Profile</span></div>
</div>
<div id="abhaMobileAlert"></div>
<!-- Step 1: Mobile Input -->
<!-- Step 1: Mobile Number Input -->
<section id="abhaMobileStep1">
<label class="form-label fw-semibold" for="abhaMobileNumber">Mobile Number</label>
<div class="input-group input-group-lg">
<span class="input-group-text"><i class="bi bi-phone"></i></span>
<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 class="card border-0 bg-light p-4 rounded-3 mb-3">
<label class="form-label fw-semibold text-dark fs-6" for="abhaMobileNumber">Enter Mobile Number</label>
<div class="input-group input-group-lg">
<span class="input-group-text bg-white"><i class="bi bi-phone"></i></span>
<input type="text" class="form-control" id="abhaMobileNumber" maxlength="10" inputmode="numeric" autocomplete="tel" placeholder="10-digit mobile number">
<button type="button" class="btn btn-primary px-4 fw-semibold" id="abhaMobileFindBtn">
Next <i class="bi bi-arrow-right ms-1"></i>
</button>
</div>
<div class="form-text mt-2 text-muted">
<i class="bi bi-info-circle me-1"></i>Enter the mobile number registered with the patient's ABHA account to view associated profiles.
</div>
</div>
<div class="form-text">Enter the mobile number registered with the patient's ABHA account to find and link it.</div>
</section>
<!-- Step 2: OTP Verification -->
<!-- Step 2: Profiles Selection & Auth Type -->
<section id="abhaMobileStep2" class="d-none">
<p class="text-muted mb-2" id="abhaMobileOtpHint"></p>
<label class="form-label fw-semibold" for="abhaMobileOtp">6-digit OTP</label>
<div class="input-group input-group-lg">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" class="form-control" id="abhaMobileOtp" maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="Enter OTP">
<div class="mb-3">
<h6 class="mb-1 fw-bold text-dark"><i class="bi bi-people text-primary me-2"></i>Select ABHA Profile</h6>
<p class="text-muted mb-0" id="abhaProfilesSubtitle">
We have found the following ABHA accounts linked with mobile <strong id="abhaMobileFoundMobile"></strong>. Select the ABHA profile you wish to login:
</p>
</div>
<div class="d-flex justify-content-between align-items-center mt-3 gap-2 flex-wrap">
<div>
<button type="button" class="btn btn-link px-0 me-3" id="abhaMobileResendBtn" disabled>Resend OTP in 60s</button>
<button type="button" class="btn btn-link px-0 text-secondary" id="abhaMobileChangeBtn">&larr; Change Mobile</button>
</div>
<button type="button" class="btn btn-primary" id="abhaMobileVerifyBtn"><i class="bi bi-patch-check me-1"></i>Verify OTP</button>
</div>
</section>
<!-- Step 2b: Multi-Account Selection (When >1 account returned) -->
<section id="abhaMobileStepAccounts" class="d-none">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-0 fw-bold text-dark"><i class="bi bi-people text-primary me-2"></i>Select ABHA Profile</h6>
<small class="text-muted" id="abhaAccountsCountHint">Multiple ABHA profiles found linked to this mobile number. Choose the patient's account:</small>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" id="abhaAccountsBackBtn">&larr; Back to OTP</button>
</div>
<div class="vstack gap-2" id="abhaAccountsList">
<!-- Profiles Radio List -->
<div class="vstack gap-2 mb-4" id="abhaProfilesList">
<!-- Populated by JavaScript -->
</div>
<!-- Authentication Type Selection -->
<div class="card border border-primary-subtle bg-light p-3 rounded-3 mb-4">
<label class="form-label fw-bold text-dark mb-2">
<i class="bi bi-shield-lock text-primary me-1"></i>Authentication type <span class="text-danger">*</span>
</label>
<div class="d-flex gap-4 flex-wrap align-items-center">
<div class="form-check">
<input class="form-check-input" type="radio" name="abhaAuthType" id="abhaAuthTypeMobile" value="MOBILE_OTP" checked>
<label class="form-check-label fw-semibold text-dark" for="abhaAuthTypeMobile">
<i class="bi bi-phone me-1 text-primary"></i>Mobile OTP
</label>
</div>
<div class="form-check" id="abhaAuthTypeAadhaarWrap">
<input class="form-check-input" type="radio" name="abhaAuthType" id="abhaAuthTypeAadhaar" value="AADHAAR_OTP">
<label class="form-check-label fw-semibold text-dark" for="abhaAuthTypeAadhaar">
<i class="bi bi-shield-check me-1 text-success"></i>Aadhaar OTP
</label>
</div>
</div>
<small class="text-muted mt-1" id="abhaAuthTypeHint">OTP will be sent to the registered mobile number.</small>
</div>
<!-- Navigation Buttons -->
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-outline-secondary px-3" id="abhaProfilesBackBtn">
<i class="bi bi-arrow-left me-1"></i>Change Mobile
</button>
<button type="button" class="btn btn-primary px-4 fw-semibold" id="abhaSendOtpBtn">
Next <i class="bi bi-arrow-right ms-1"></i>
</button>
</div>
</section>
<!-- Step 3: Verified Profile & Card -->
<!-- Step 3: OTP Verification -->
<section id="abhaMobileStep3" class="d-none">
<div class="card border-0 bg-light p-4 rounded-3 mb-3">
<h6 class="fw-bold text-dark mb-1"><i class="bi bi-key text-primary me-2"></i>Confirm OTP</h6>
<p class="text-muted mb-3" id="abhaMobileOtpHint">OTP sent to mobile number ending with ******3177</p>
<label class="form-label fw-semibold text-dark" for="abhaMobileOtp">Enter 6-digit OTP</label>
<div class="input-group input-group-lg" style="max-width:380px;">
<span class="input-group-text bg-white"><i class="bi bi-shield-check"></i></span>
<input type="text" class="form-control letter-spacing-2" id="abhaMobileOtp" maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="6-digit OTP">
</div>
<div class="d-flex justify-content-between align-items-center mt-4 gap-2 flex-wrap">
<div>
<button type="button" class="btn btn-link px-0 me-3" id="abhaMobileResendBtn" disabled>Resend OTP in 60s</button>
<button type="button" class="btn btn-link px-0 text-secondary" id="abhaBackToProfilesBtn">&larr; Back to Profiles</button>
</div>
<button type="button" class="btn btn-primary px-4 fw-semibold" id="abhaMobileVerifyBtn">
<i class="bi bi-patch-check me-1"></i>Verify OTP
</button>
</div>
</div>
</section>
<!-- Step 4: Complete ABDM Profile Fetched & Linking -->
<section id="abhaMobileStep4" class="d-none">
<div class="row g-4">
<div class="col-lg-6">
<div class="d-flex align-items-center gap-3 mb-3">
<img id="abhaMobilePhoto" class="rounded d-none" alt="ABHA profile" style="width:76px;height:76px;object-fit:cover">
<div>
<span class="badge bg-success-subtle text-success mb-1"><i class="bi bi-patch-check-fill me-1"></i>ABHA Verified</span>
<h4 class="mb-0" id="abhaMobileProfileName"></h4>
<div class="text-muted" id="abhaMobileProfileAddress"></div>
<h4 class="mb-0 fw-bold text-dark" id="abhaMobileProfileName"></h4>
<div class="text-muted fw-semibold" id="abhaMobileProfileAddress"></div>
</div>
</div>
<dl class="abha-profile-list">
@ -152,14 +204,22 @@
</dl>
</div>
<div class="col-lg-6">
<div class="abha-card-preview" id="abhaMobileCardWrap"><div class="text-center text-muted"><i class="bi bi-card-image fs-1 d-block mb-2"></i><span>Official ABHA card was not returned by the Bridge.</span></div></div>
<div class="abha-card-preview" id="abhaMobileCardWrap">
<div class="text-center text-muted">
<i class="bi bi-card-image fs-1 d-block mb-2"></i>
<span>Official ABHA card preview.</span>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-4 gap-2 flex-wrap">
<div>
<button type="button" class="btn btn-outline-secondary d-none" id="abhaChangeAccountBtn">&larr; Choose Different ABHA Account</button>
</div>
<button type="button" class="btn btn-success" id="abhaMobileCompareBtn"><i class="bi bi-people me-1"></i>Compare with HMS Patients</button>
<button type="button" class="btn btn-outline-secondary" id="abhaChooseDifferentProfileBtn">
&larr; Choose Different Profile
</button>
<button type="button" class="btn btn-success px-4 fw-semibold" id="abhaMobileCompareBtn">
<i class="bi bi-people me-1"></i>Compare with HMS Patients
</button>
</div>
</section>
</div>
@ -171,12 +231,14 @@
window.AbhaMobileModal = (function () {
'use strict';
var modal;
var txnId = '';
var currentToken = '';
var verifiedMobile = '';
var modal = null;
var initialized = false;
var searchTxnId = '';
var otpTxnId = '';
var searchMobile = '';
var foundProfiles = [];
var selectedProfile = null;
var verifiedProfile = null;
var linkedAccounts = [];
var onVerified = null;
var resendTimer = null;
var resendRemaining = 0;
@ -185,29 +247,42 @@ window.AbhaMobileModal = (function () {
var input = document.querySelector('input[name="<?= csrf_token() ?>"]');
return input ? input.value : '<?= csrf_hash() ?>';
}
function escapeHtml(value) { return $('<div>').text(value == null ? '' : String(value)).html(); }
function escapeHtml(value) {
if (value == null) return '';
var div = document.createElement('div');
div.textContent = String(value);
return div.innerHTML;
}
function digits(value) { return String(value == null ? '' : value).replace(/\D/g, ''); }
function apiMessage(response, fallback) {
return response && (response.error_text || response.message) ? escapeHtml(response.error_text || response.message) : fallback;
}
function alertBox(type, message) {
$('#abhaMobileAlert').html(message ? '<div class="alert alert-' + type + ' py-2">' + message + '</div>' : '');
var alertEl = document.getElementById('abhaMobileAlert');
if (!alertEl) return;
alertEl.innerHTML = message ? '<div class="alert alert-' + type + ' py-2">' + message + '</div>' : '';
}
function showStep(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);
});
for (var i = 1; i <= 4; i++) {
var stepEl = document.getElementById('abhaMobileStep' + i);
if (stepEl) {
if (i === step) stepEl.classList.remove('d-none');
else stepEl.classList.add('d-none');
}
}
var stepHeaders = document.querySelectorAll('.abha-mobile-step');
stepHeaders.forEach(function (header) {
var itemStep = Number(header.getAttribute('data-step'));
if (itemStep === step) {
header.classList.add('active');
header.classList.remove('done');
} else if (itemStep < step) {
header.classList.remove('active');
header.classList.add('done');
} else {
header.classList.remove('active', 'done');
}
});
alertBox('', '');
}
function stopTimer() {
@ -217,14 +292,17 @@ window.AbhaMobileModal = (function () {
function startResendTimer(seconds) {
stopTimer();
resendRemaining = Math.max(60, Number(seconds) || 60);
var button = $('#abhaMobileResendBtn').prop('disabled', true);
var button = document.getElementById('abhaMobileResendBtn');
if (button) button.disabled = true;
function tick() {
if (!button) return;
if (resendRemaining <= 0) {
stopTimer();
button.prop('disabled', false).text('Resend OTP');
button.disabled = false;
button.textContent = 'Resend OTP';
return;
}
button.text('Resend OTP in ' + resendRemaining + 's');
button.textContent = 'Resend OTP in ' + resendRemaining + 's';
resendRemaining--;
}
tick();
@ -243,225 +321,445 @@ window.AbhaMobileModal = (function () {
return mobile.length === 10 ? mobile.replace(/(\d{6})(\d{4})/, '******$2') : (value || '-');
}
function sendOtp() {
var mobile = digits($('#abhaMobileNumber').val());
// Step 1: Search ABHA profiles by mobile number
function searchProfiles() {
var numInput = document.getElementById('abhaMobileNumber');
var mobile = digits(numInput ? numInput.value : '');
if (mobile.length !== 10) { alertBox('warning', 'Enter a valid 10-digit mobile number.'); return; }
verifiedMobile = mobile;
linkedAccounts = [];
var button = $('#abhaMobileSendOtpBtn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Sending');
$.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 || '';
$('#abhaMobileOtp').val('');
$('#abhaMobileOtpHint').html('OTP sent to <strong>' + escapeHtml(maskMobile(mobile)) + '</strong>.');
showStep(2);
startResendTimer(60);
$('#abhaMobileOtp').trigger('focus');
}, 'json').fail(function (xhr) {
button.prop('disabled', false).html('<i class="bi bi-send me-1"></i>Send OTP');
alertBox('danger', apiMessage(xhr.responseJSON, 'Unable to send OTP to this mobile number.'));
});
}
searchMobile = mobile;
foundProfiles = [];
selectedProfile = null;
var button = document.getElementById('abhaMobileFindBtn');
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Searching...';
}
function verifyOtp() {
var otp = digits($('#abhaMobileOtp').val());
if (otp.length !== 6) { alertBox('warning', 'Enter the 6-digit OTP.'); return; }
var postData = new URLSearchParams();
postData.append('mobile', mobile);
postData.append('<?= csrf_token() ?>', csrf());
var button = $('#abhaMobileVerifyBtn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Verifying');
$.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();
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);
fetch('<?= base_url('abha/find/mobile/search') ?>', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: postData.toString()
})
.then(function (res) { return res.json(); })
.then(function (response) {
if (button) {
button.disabled = false;
button.innerHTML = 'Next <i class="bi bi-arrow-right ms-1"></i>';
}
}, 'json').fail(function (xhr) {
button.prop('disabled', false).html('<i class="bi bi-patch-check me-1"></i>Verify OTP');
alertBox('danger', apiMessage(xhr.responseJSON, 'OTP verification failed.'));
if (!response || response.ok != 1 || !Array.isArray(response.profiles) || response.profiles.length === 0) {
alertBox('danger', apiMessage(response, 'No ABHA profiles found linked to this mobile number.'));
return;
}
searchTxnId = response.txn_id || '';
foundProfiles = response.profiles;
renderProfilesList(foundProfiles);
showStep(2);
})
.catch(function (err) {
if (button) {
button.disabled = false;
button.innerHTML = 'Next <i class="bi bi-arrow-right ms-1"></i>';
}
alertBox('danger', 'Unable to find ABHA profiles for this mobile number.');
});
}
function renderAccountsList(accounts, defaultResponse) {
$('#abhaAccountsCountHint').text('Found ' + accounts.length + ' ABHA accounts linked with mobile ' + maskMobile(verifiedMobile) + '. Select the patient:');
var container = $('#abhaAccountsList').empty();
// Step 2: Render profiles list with radio buttons
function renderProfilesList(profiles) {
var mobileSpan = document.getElementById('abhaMobileFoundMobile');
if (mobileSpan) mobileSpan.textContent = maskMobile(searchMobile);
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 container = document.getElementById('abhaProfilesList');
if (!container) return;
container.innerHTML = '';
profiles.forEach(function (prof, index) {
var isSelected = (index === 0);
if (isSelected) selectedProfile = prof;
var name = prof.name || 'ABHA Profile #' + (index + 1);
var abhaNum = prof.abha_number || prof.ABHANumber || prof.abha_id || '-';
var gender = genderText(prof.gender);
var dob = prof.dob || '-';
var isVerified = prof.kyc_verified !== false;
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
? '<img src="' + (String(acc.profilePhoto).indexOf('data:') === 0 ? acc.profilePhoto : 'data:image/jpeg;base64,' + acc.profilePhoto) + '" class="rounded-circle" style="width:44px;height:44px;object-fit:cover">'
: '<div class="abha-account-avatar"><i class="bi ' + iconClass + '"></i></div>';
var photoHtml = prof.profile_photo
? '<img src="' + (String(prof.profile_photo).indexOf('data:') === 0 ? prof.profile_photo : 'data:image/jpeg;base64,' + prof.profile_photo) + '" class="rounded-circle" style="width:48px;height:48px;object-fit:cover">'
: '<div class="abha-profile-avatar"><i class="bi ' + iconClass + '"></i></div>';
var cardHtml = $(
'<div class="abha-account-card d-flex align-items-center justify-content-between gap-3" data-index="' + index + '">' +
'<div class="d-flex align-items-center gap-3">' +
photoHtml +
'<div>' +
'<div class="d-flex align-items-center gap-2 flex-wrap">' +
'<h6 class="mb-0 fw-bold text-dark">' + escapeHtml(name) + '</h6>' +
'<span class="badge bg-primary-subtle text-primary border border-primary-subtle">' + escapeHtml(abhaAddr) + '</span>' +
(status === 'ACTIVE' ? '<span class="badge bg-success-subtle text-success">Active</span>' : '') +
'</div>' +
'<div class="text-muted small mt-1">' +
'<span class="me-3"><i class="bi bi-credit-card-2-front me-1"></i>ABHA: <strong>' + escapeHtml(formatAbha(abhaNum)) + '</strong></span>' +
'<span class="me-3"><i class="bi bi-gender-ambiguous me-1"></i>' + escapeHtml(gender) + '</span>' +
'<span><i class="bi bi-calendar-event me-1"></i>DOB: ' + escapeHtml(dob) + '</span>' +
'</div>' +
var card = document.createElement('div');
card.className = 'abha-profile-card d-flex align-items-center justify-content-between gap-3 ' + (isSelected ? 'selected' : '');
card.setAttribute('data-index', index);
card.innerHTML =
'<div class="d-flex align-items-center gap-3">' +
'<div class="form-check m-0">' +
'<input class="form-check-input profile-radio" type="radio" name="abhaProfileOption" value="' + index + '" ' + (isSelected ? 'checked' : '') + '>' +
'</div>' +
photoHtml +
'<div>' +
'<div class="d-flex align-items-center gap-2 flex-wrap">' +
'<h6 class="mb-0 fw-bold text-dark">' + escapeHtml(name) + '</h6>' +
(isVerified ? '<span class="badge bg-success-subtle text-success border border-success-subtle"><i class="bi bi-patch-check-fill me-1"></i>Active</span>' : '') +
'</div>' +
'<div class="text-muted small mt-1">' +
'<span class="me-3"><i class="bi bi-credit-card-2-front me-1"></i>ABHA: <strong>' + escapeHtml(formatAbha(abhaNum)) + '</strong></span>' +
'<span class="me-3"><i class="bi bi-gender-ambiguous me-1"></i>' + escapeHtml(gender) + '</span>' +
(dob && dob !== '-' ? '<span><i class="bi bi-calendar-event me-1"></i>DOB: ' + escapeHtml(dob) + '</span>' : '') +
'</div>' +
'</div>' +
'<div>' +
'<button type="button" class="btn btn-sm btn-outline-primary select-acc-btn">' +
'<i class="bi bi-check2-circle me-1"></i>Select Account' +
'</button>' +
'</div>' +
'</div>'
);
'</div>';
cardHtml.on('click', function () {
selectAccount(acc, cardHtml);
card.addEventListener('click', function () {
document.querySelectorAll('.abha-profile-card').forEach(function (c) { c.classList.remove('selected'); });
card.classList.add('selected');
var radio = card.querySelector('.profile-radio');
if (radio) radio.checked = true;
selectedProfile = prof;
updateAuthMethods(prof);
});
container.append(cardHtml);
container.appendChild(card);
});
showStep('accounts');
if (profiles.length > 0) {
updateAuthMethods(profiles[0]);
}
}
function selectAccount(account, cardElement) {
if (cardElement) {
$('.abha-account-card').removeClass('selected');
cardElement.addClass('selected');
cardElement.find('.select-acc-btn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Loading');
function updateAuthMethods(profile) {
var methods = Array.isArray(profile.auth_methods) ? profile.auth_methods : ['MOBILE_OTP', 'AADHAAR_OTP'];
var supportsAadhaar = methods.some(function(m) { return String(m).toUpperCase().indexOf('AADHAAR') !== -1; });
var aadhaarWrap = document.getElementById('abhaAuthTypeAadhaarWrap');
if (aadhaarWrap) {
if (supportsAadhaar) aadhaarWrap.classList.remove('d-none');
else aadhaarWrap.classList.add('d-none');
}
var aadhaarRadio = document.getElementById('abhaAuthTypeAadhaar');
var mobileRadio = document.getElementById('abhaAuthTypeMobile');
if (!supportsAadhaar && aadhaarRadio && aadhaarRadio.checked && mobileRadio) {
mobileRadio.checked = true;
}
}
// Step 2 -> 3: Request OTP for the chosen profile
function requestOtp() {
if (!selectedProfile) {
alertBox('warning', 'Please select an ABHA profile to proceed.');
return;
}
var abhaNum = account.ABHANumber || account.abhaNumber || account.abha_id || '';
var abhaAddr = account.preferredAbhaAddress || account.preferredAddress || account.abhaAddress || account.abha_address || '';
var authMethodInput = document.querySelector('input[name="abhaAuthType"]:checked');
var authMethod = authMethodInput ? authMethodInput.value : 'MOBILE_OTP';
var profileIndex = selectedProfile.index || '1';
$.post('<?= base_url('abha/find/mobile/select-account') ?>', {
txn_id: txnId,
token: currentToken,
abha_number: abhaNum,
abha_address: abhaAddr,
mobile: verifiedMobile,
account: JSON.stringify(account),
find_abha: 1,
'<?= csrf_token() ?>': csrf()
}, function (response) {
if (cardElement) {
cardElement.find('.select-acc-btn').prop('disabled', false).html('<i class="bi bi-check2-circle me-1"></i>Select Account');
var button = document.getElementById('abhaSendOtpBtn');
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending OTP...';
}
var postData = new URLSearchParams();
postData.append('txn_id', searchTxnId);
postData.append('index', profileIndex);
postData.append('auth_method', authMethod);
postData.append('mobile', searchMobile);
postData.append('<?= csrf_token() ?>', csrf());
fetch('<?= base_url('abha/find/mobile/request-otp') ?>', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: postData.toString()
})
.then(function (res) { return res.json(); })
.then(function (response) {
if (button) {
button.disabled = false;
button.innerHTML = 'Next <i class="bi bi-arrow-right ms-1"></i>';
}
if (!response || response.ok != 1) {
alertBox('danger', apiMessage(response, 'Failed to select ABHA account.'));
alertBox('danger', apiMessage(response, 'Failed to send OTP to registered mobile.'));
return;
}
renderProfile(response);
}, 'json').fail(function (xhr) {
if (cardElement) {
cardElement.find('.select-acc-btn').prop('disabled', false).html('<i class="bi bi-check2-circle me-1"></i>Select Account');
otpTxnId = response.txn_id || searchTxnId;
var hintMsg = response.message || ('OTP sent to mobile number ending with ' + maskMobile(searchMobile));
var hintEl = document.getElementById('abhaMobileOtpHint');
if (hintEl) hintEl.textContent = hintMsg;
var otpInput = document.getElementById('abhaMobileOtp');
if (otpInput) otpInput.value = '';
showStep(3);
startResendTimer(60);
window.setTimeout(function () {
var el = document.getElementById('abhaMobileOtp');
if (el) el.focus();
}, 200);
})
.catch(function () {
if (button) {
button.disabled = false;
button.innerHTML = 'Next <i class="bi bi-arrow-right ms-1"></i>';
}
alertBox('danger', apiMessage(xhr.responseJSON, 'Failed to select ABHA account.'));
alertBox('danger', 'Failed to send OTP to registered mobile.');
});
}
function renderProfile(profile) {
verifiedProfile = profile;
$('#abhaMobileProfileName').text(profile.name || '-');
$('#abhaMobileProfileAddress').text(profile.abha_address || '-');
$('#abhaMobileProfileNumber').text(formatAbha(profile.abha_number) || '-');
$('#abhaMobileProfileId').text(profile.abha_address || '-');
$('#abhaMobileProfileDob').text(profile.dob || '-');
$('#abhaMobileProfileGender').text(genderText(profile.gender));
$('#abhaMobileProfileMobile').text(maskMobile(profile.mobile || verifiedMobile));
$('#abhaMobileProfileFullAddress').text([profile.address, profile.district, profile.state, profile.zip].filter(Boolean).join(', ') || '-');
// Step 3 -> 4: Verify OTP and render complete profile
function verifyOtp() {
var otpInput = document.getElementById('abhaMobileOtp');
var otp = digits(otpInput ? otpInput.value : '');
if (otp.length !== 6) { alertBox('warning', 'Enter the 6-digit OTP.'); return; }
var photo = profile.photo ? (String(profile.photo).indexOf('data:') === 0 ? profile.photo : 'data:image/jpeg;base64,' + profile.photo) : '';
$('#abhaMobilePhoto').toggleClass('d-none', !photo).attr('src', photo || '');
var authMethodInput = document.querySelector('input[name="abhaAuthType"]:checked');
var authMethod = authMethodInput ? authMethodInput.value : 'MOBILE_OTP';
var card = profile.card_base64 || '';
if (card) {
var cardSrc = String(card).indexOf('data:') === 0 ? card : 'data:' + (profile.card_content_type || 'image/png') + ';base64,' + card;
var note = profile.card_source !== 'generated'
? ''
: '<div class="small text-warning mt-1"><i class="bi bi-exclamation-triangle me-1"></i>Provisional card generated by the Bridge, not the official ABDM card.</div>';
$('#abhaMobileCardWrap').html('<img src="' + cardSrc + '" alt="ABHA card">' + note);
} else {
var reason = profile.card_message ? escapeHtml(profile.card_message) : 'ABDM did not issue an official ABHA card for this session.';
$('#abhaMobileCardWrap').html('<div class="text-center text-muted"><i class="bi bi-card-image fs-1 d-block mb-2"></i>' + reason + '</div>');
var button = document.getElementById('abhaMobileVerifyBtn');
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Verifying...';
}
// Show "Choose Different Account" button if multiple accounts exist
$('#abhaChangeAccountBtn').toggleClass('d-none', !(linkedAccounts && linkedAccounts.length > 1));
var postData = new URLSearchParams();
postData.append('txn_id', otpTxnId);
postData.append('otp', otp);
postData.append('auth_method', authMethod);
postData.append('mobile', searchMobile);
postData.append('<?= csrf_token() ?>', csrf());
showStep(3);
fetch('<?= base_url('abha/find/mobile/verify-otp') ?>', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: postData.toString()
})
.then(function (res) { return res.json(); })
.then(function (response) {
if (button) {
button.disabled = false;
button.innerHTML = '<i class="bi bi-patch-check me-1"></i>Verify OTP';
}
if (!response || response.ok != 1) {
alertBox('danger', apiMessage(response, 'OTP verification failed. Please check the code.'));
return;
}
stopTimer();
renderCompleteProfile(response);
})
.catch(function () {
if (button) {
button.disabled = false;
button.innerHTML = '<i class="bi bi-patch-check me-1"></i>Verify OTP';
}
alertBox('danger', 'OTP verification failed.');
});
}
$(function () {
modal = new bootstrap.Modal(document.getElementById('abhaMobileModal'));
// Step 4: Render complete profile and card
function renderCompleteProfile(profile) {
verifiedProfile = profile;
var elName = document.getElementById('abhaMobileProfileName');
var elAddr = document.getElementById('abhaMobileProfileAddress');
var elNum = document.getElementById('abhaMobileProfileNumber');
var elId = document.getElementById('abhaMobileProfileId');
var elDob = document.getElementById('abhaMobileProfileDob');
var elGender = document.getElementById('abhaMobileProfileGender');
var elMobile = document.getElementById('abhaMobileProfileMobile');
var elFullAddr = document.getElementById('abhaMobileProfileFullAddress');
$('#abhaMobileSendOtpBtn').on('click', sendOtp);
$('#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');
if (elName) elName.textContent = profile.name || '-';
if (elAddr) elAddr.textContent = profile.abha_address || '-';
if (elNum) elNum.textContent = formatAbha(profile.abha_number) || '-';
if (elId) elId.textContent = profile.abha_address || '-';
if (elDob) elDob.textContent = profile.dob || '-';
if (elGender) elGender.textContent = genderText(profile.gender);
if (elMobile) elMobile.textContent = maskMobile(profile.mobile || searchMobile);
if (elFullAddr) elFullAddr.textContent = [profile.address, profile.district, profile.state, profile.zip].filter(Boolean).join(', ') || '-';
var photo = profile.photo ? (String(profile.photo).indexOf('data:') === 0 ? profile.photo : 'data:image/jpeg;base64,' + profile.photo) : '';
var photoEl = document.getElementById('abhaMobilePhoto');
if (photoEl) {
if (photo) {
photoEl.classList.remove('d-none');
photoEl.src = photo;
} else {
photoEl.classList.add('d-none');
photoEl.src = '';
}
});
$('#abhaMobileNumber').on('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); sendOtp(); } });
$('#abhaMobileOtp').on('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); verifyOtp(); } });
}
$('#abhaMobileCompareBtn').on('click', function () {
var card = profile.card_base64 || '';
var cardWrap = document.getElementById('abhaMobileCardWrap');
if (cardWrap) {
if (card) {
var cardSrc = String(card).indexOf('data:') === 0 ? card : 'data:' + (profile.card_content_type || 'image/png') + ';base64,' + card;
var note = profile.card_source !== 'generated'
? ''
: '<div class="small text-warning mt-2"><i class="bi bi-exclamation-triangle me-1"></i>Provisional card generated by Bridge.</div>';
var downloadBtn = '<div class="mt-2 text-center"><a href="' + cardSrc + '" download="ABHA_Card_' + (profile.abha_number || 'card') + '.png" class="btn btn-sm btn-outline-primary"><i class="bi bi-download me-1"></i>Download ABHA Card</a></div>';
cardWrap.innerHTML = '<div class="text-center w-100"><img src="' + cardSrc + '" alt="ABHA card" class="img-fluid rounded mb-2" style="max-height:360px;">' + downloadBtn + note + '</div>';
} else {
var reason = profile.card_message ? escapeHtml(profile.card_message) : 'Official ABHA card preview was not returned.';
cardWrap.innerHTML = '<div class="text-center text-muted p-4"><i class="bi bi-card-image fs-1 d-block mb-2"></i>' + reason + '</div>';
}
}
var chooseDiffBtn = document.getElementById('abhaChooseDifferentProfileBtn');
if (chooseDiffBtn) {
if (foundProfiles && foundProfiles.length > 1) chooseDiffBtn.classList.remove('d-none');
else chooseDiffBtn.classList.add('d-none');
}
showStep(4);
}
function ensureInit() {
if (initialized) return true;
var modalEl = document.getElementById('abhaMobileModal');
if (!modalEl || typeof window.bootstrap === 'undefined') return false;
modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
var findBtn = document.getElementById('abhaMobileFindBtn');
if (findBtn) findBtn.onclick = searchProfiles;
var sendOtpBtn = document.getElementById('abhaSendOtpBtn');
if (sendOtpBtn) sendOtpBtn.onclick = requestOtp;
var resendBtn = document.getElementById('abhaMobileResendBtn');
if (resendBtn) resendBtn.onclick = requestOtp;
var verifyBtn = document.getElementById('abhaMobileVerifyBtn');
if (verifyBtn) verifyBtn.onclick = verifyOtp;
var backBtn = document.getElementById('abhaProfilesBackBtn');
if (backBtn) backBtn.onclick = function () {
showStep(1);
var el = document.getElementById('abhaMobileNumber');
if (el) el.focus();
};
var backToProfBtn = document.getElementById('abhaBackToProfilesBtn');
if (backToProfBtn) backToProfBtn.onclick = function () {
stopTimer();
showStep(2);
};
var diffBtn = document.getElementById('abhaChooseDifferentProfileBtn');
if (diffBtn) diffBtn.onclick = function () {
showStep(2);
};
var numInput = document.getElementById('abhaMobileNumber');
if (numInput) numInput.onkeydown = function (event) {
if (event.key === 'Enter') {
event.preventDefault();
searchProfiles();
}
};
var otpInput = document.getElementById('abhaMobileOtp');
if (otpInput) otpInput.onkeydown = function (event) {
if (event.key === 'Enter') {
event.preventDefault();
verifyOtp();
}
};
var compareBtn = document.getElementById('abhaMobileCompareBtn');
if (compareBtn) compareBtn.onclick = function () {
if (!verifiedProfile) return;
var profile = verifiedProfile;
// Already linked: nothing else opens, so keep this window up for the operator to close.
if (profile.need_confirmation === false && Number(profile.patient_id || 0) > 0) {
alertBox('success', 'This ABHA is already linked to HMS patient <strong>' + escapeHtml(profile.p_code || '') + '</strong>. The page behind has been updated. Close this window when you are done.');
alertBox('success', 'This ABHA is already linked to HMS patient <strong>' + escapeHtml(profile.p_code || '') + '</strong>. Close this window when done.');
if (typeof onVerified === 'function') onVerified(profile);
return;
}
$('#abhaMobileModal').one('hidden.bs.modal', function () {
modalEl.addEventListener('hidden.bs.modal', function handler() {
modalEl.removeEventListener('hidden.bs.modal', handler);
if (typeof onVerified === 'function') onVerified(profile);
});
modal.hide();
});
};
$('#abhaMobileModal').on('hidden.bs.modal', stopTimer);
});
modalEl.addEventListener('hidden.bs.modal', stopTimer);
initialized = true;
return true;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensureInit);
} else {
ensureInit();
}
window.addEventListener('load', ensureInit);
return {
open: function (prefillMobile, callback) {
ensureInit();
if (!modal) {
var modalEl = document.getElementById('abhaMobileModal');
if (modalEl && typeof window.bootstrap !== 'undefined') {
modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
}
}
onVerified = typeof prefillMobile === 'function' ? prefillMobile : callback;
txnId = '';
currentToken = '';
verifiedMobile = '';
searchTxnId = '';
otpTxnId = '';
searchMobile = '';
foundProfiles = [];
selectedProfile = null;
verifiedProfile = null;
linkedAccounts = [];
stopTimer();
$('#abhaMobileNumber').val(typeof prefillMobile === 'string' ? prefillMobile : '');
$('#abhaMobileOtp').val('');
$('#abhaMobilePhoto').addClass('d-none');
$('#abhaChangeAccountBtn').addClass('d-none');
var numInput = document.getElementById('abhaMobileNumber');
if (numInput) numInput.value = typeof prefillMobile === 'string' ? prefillMobile : '';
var otpInput = document.getElementById('abhaMobileOtp');
if (otpInput) otpInput.value = '';
var photoEl = document.getElementById('abhaMobilePhoto');
if (photoEl) {
photoEl.classList.add('d-none');
photoEl.src = '';
}
var listEl = document.getElementById('abhaProfilesList');
if (listEl) listEl.innerHTML = '';
showStep(1);
modal.show();
window.setTimeout(function () { $('#abhaMobileNumber').trigger('focus'); }, 300);
if (modal) {
modal.show();
} else {
// Fallback using jQuery if bootstrap object not directly on window
if (typeof window.jQuery !== 'undefined') {
window.jQuery('#abhaMobileModal').modal('show');
}
}
window.setTimeout(function () {
var el = document.getElementById('abhaMobileNumber');
if (el) el.focus();
}, 300);
}
};
})();