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:
parent
c82892f3d2
commit
9176877d07
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -157,4 +157,5 @@ writable/debug/*
|
|||
writable/github_update_cache.json
|
||||
E-AtriaHMS.docx
|
||||
E-AtriaHMS.zip
|
||||
scratch/
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
488
app/Libraries/Abdm/AbdmV3DirectHelper.php
Normal file
488
app/Libraries/Abdm/AbdmV3DirectHelper.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 & 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">← 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">← 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">← 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">← 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">
|
||||
← 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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user