Complete ABHA account verification flow

This commit is contained in:
DevSoftTech Server 2026-08-13 10:36:20 +05:30
parent 35fcb07434
commit a5447cb83a
12 changed files with 1019 additions and 117 deletions

View File

@ -775,6 +775,8 @@ $routes->post('abha/create/confirm_patient', 'Abha::confirmPatient', ['filter' =
$routes->post('abha/create/address', 'Abha::address', ['filter' => 'permission:abdm.abha.create']);
$routes->get('abha/card/(:segment)', 'Abha::card/$1', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/register/validate', 'Abha::validateAbha', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/register/login/request-otp', 'Abha::loginRequestOtp', ['filter' => 'permission:abdm.abha.create']);
$routes->post('abha/register/login/verify-otp', 'Abha::loginVerifyOtp', ['filter' => 'permission:abdm.abha.create']);
$routes->get('abha/register/facility_qr', 'Abha::facilityQr', ['filter' => 'permission:abdm.abha.create']);
$routes->get('AbdmTaskBoard', 'AbdmTaskBoard::index', ['filter' => $abdmPermFilter]);
$routes->get('AbdmTaskBoard/list', 'AbdmTaskBoard::list', ['filter' => $abdmPermFilter]);

View File

@ -731,16 +731,35 @@ class Abha extends BaseController
*/
private function extractBridgeErrorText(array $result, string $fallback): string
{
$fieldErrors = [];
if (is_array($result['data'] ?? null)) {
foreach ($result['data'] as $field => $message) {
if ($field === 'timestamp' || ! is_string($message) || trim($message) === '') {
continue;
}
$fieldErrors[] = $field . ': ' . trim($message);
}
}
if ($fieldErrors !== []) {
return implode('; ', $fieldErrors);
}
$candidates = [
$result['error_text'] ?? null,
$result['message'] ?? null,
$result['error']['message'] ?? null,
$result['error']['description'] ?? null,
$result['error']['code'] ?? null,
$result['error']['error'] ?? null,
$result['error']['error_description'] ?? null,
$result['data']['message'] ?? null,
$result['data']['error']['message'] ?? null,
$result['data']['error']['description'] ?? null,
$result['data']['error']['code'] ?? null,
$result['data']['error']['error'] ?? null,
$result['data']['error']['error_description'] ?? null,
$result['details']['message'] ?? null,
$result['details']['error'] ?? null,
$result['description'] ?? null,
$result['data']['description'] ?? null,
];
@ -766,6 +785,7 @@ class Abha extends BaseController
}
$action = trim((string) ($this->request->getPost('action') ?? ''));
$updateMode = trim((string) ($this->request->getPost('update_mode') ?? 'update'));
$patientId = (int) ($this->request->getPost('patient_id') ?? 0);
$abhaNum = (string) ($this->request->getPost('abha_number') ?? '');
$name = trim((string) ($this->request->getPost('name') ?? ''));
@ -776,6 +796,9 @@ class Abha extends BaseController
if (! in_array($action, ['new', 'existing'], true)) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid action. Must be "new" or "existing".']);
}
if (! in_array($updateMode, ['keep', 'update'], true)) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid patient update mode.']);
}
$abhaMeta = [
'abha_address' => (string) ($this->request->getPost('abha_address') ?? ''),
@ -841,7 +864,20 @@ class Abha extends BaseController
]);
}
$patientInfo = $this->linkAbhaToPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField);
$patientInfo = $this->linkAbhaToPatient(
$db,
$patientId,
$updateMode === 'update' ? $name : '',
$updateMode === 'update' ? $mobile : '',
$updateMode === 'update' ? $gender : '',
$updateMode === 'update' ? $dob : '',
$updateMode === 'update' ? $abhaMeta : array_diff_key($abhaMeta, array_flip(['address', 'district', 'state', 'zip', 'email'])),
$abhaNumClean,
$abhaAddress,
$fields,
$abhaField,
$updateMode === 'update'
);
} else {
$patientInfo = $this->createPatientFromAbha($db, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField);
}
@ -987,7 +1023,11 @@ class Abha extends BaseController
return [];
}
$select = 'id,p_code,p_fname,p_lname,gender,dob,age,mphone1,udai';
$selectFields = array_values(array_intersect(
['id', 'p_code', 'p_fname', 'p_lname', 'gender', 'dob', 'age', 'mphone1', 'udai', 'add1', 'district', 'state', 'zip'],
$fields
));
$select = implode(',', $selectFields);
if ($abhaField !== null && ! in_array($abhaField, ['id', 'p_code', 'p_fname', 'p_lname', 'gender', 'dob', 'age', 'mphone1', 'udai'], true)) {
$select .= ',' . $abhaField;
}
@ -1060,6 +1100,12 @@ class Abha extends BaseController
'dob' => $rowDob,
'age' => $rowAgeYears,
'mobile' => (string) ($row['mphone1'] ?? ''),
'address' => trim(implode(', ', array_filter([
(string) ($row['add1'] ?? ''),
(string) ($row['district'] ?? ''),
(string) ($row['state'] ?? ''),
(string) ($row['zip'] ?? ''),
]))),
'aadhaar' => (string) ($row['udai'] ?? ''),
'abha' => $rowAbha,
'abha_conflict' => $abhaConflict,
@ -1099,21 +1145,22 @@ class Abha extends BaseController
string $abhaNumClean,
string $abhaAddress,
array $fields,
?string $abhaField
?string $abhaField,
bool $updateDetails = false
): array {
$existing = $db->table('patient_master')->where('id', $patientId)->get()->getRowArray() ?? [];
$pCode = (string) ($existing['p_code'] ?? '');
$existingUpdates = [];
$existingName = strtoupper(trim((string) ($existing['p_fname'] ?? '')));
if ($name !== '' && ($existingName === '' || $existingName === 'ABHA PATIENT')) {
if ($name !== '' && ($updateDetails || $existingName === '' || $existingName === 'ABHA PATIENT')) {
$existingUpdates['p_fname'] = strtoupper($name);
}
// Backfill DOB/gender/mobile from ABHA profile only if not already recorded,
// so we never clobber a manually-corrected value.
$existingDob = trim((string) ($existing['dob'] ?? ''));
if (($existingDob === '' || $existingDob === '0000-00-00') && $dob !== '') {
if (($updateDetails || $existingDob === '' || $existingDob === '0000-00-00') && $dob !== '') {
$dobDb = $this->normalizeDobToDb($dob);
if ($dobDb !== '') {
$existingUpdates['dob'] = $dobDb;
@ -1123,10 +1170,10 @@ class Abha extends BaseController
}
}
$existingGenderRaw = (int) ($existing['gender'] ?? 0);
if ($existingGenderRaw <= 0 && $gender !== '') {
if (($updateDetails || $existingGenderRaw <= 0) && $gender !== '') {
$existingUpdates['gender'] = $this->toPatientGenderValue($gender);
}
if ($mobile !== '' && empty($existing['mphone1'])) {
if ($mobile !== '' && ($updateDetails || empty($existing['mphone1']))) {
$existingUpdates['mphone1'] = $mobile;
}
@ -1440,11 +1487,26 @@ class Abha extends BaseController
*/
private function pickGatewayAbhaProfile(array $payload): array
{
$account = is_array($payload['account'] ?? null) ? $payload['account'] : [];
$loginAccount = is_array($payload['accounts'][0] ?? null) ? $payload['accounts'][0] : [];
$mergedAccount = $loginAccount;
foreach ($account as $key => $value) {
if (! is_scalar($value) || trim((string) $value) === '') {
continue;
}
if (in_array(strtoupper(trim((string) $value)), ['PATIENT', 'PATIENT NAME', 'N/A'], true)) {
continue;
}
$mergedAccount[$key] = $value;
}
$candidates = [
$payload['ABHAProfile'] ?? null,
$payload['gateway_abha_profile'] ?? null,
$payload['profile'] ?? null,
$payload['accounts'][0] ?? null,
$mergedAccount,
$account,
$loginAccount,
$payload['gateway_patient'] ?? null,
$payload,
];
@ -1464,7 +1526,7 @@ class Abha extends BaseController
*/
private function extractAbhaProfileName(array $profile, array $payload): string
{
foreach ([$profile, $payload, $payload['gateway_abha_profile'] ?? [], $payload['gateway_patient'] ?? []] as $source) {
foreach ([$profile, $payload, $payload['account'] ?? [], $payload['gateway_abha_profile'] ?? [], $payload['gateway_patient'] ?? []] as $source) {
if (! is_array($source)) {
continue;
}
@ -1541,11 +1603,17 @@ class Abha extends BaseController
$result['error'] ?? null,
$result['error']['message'] ?? null,
$result['error']['description'] ?? null,
$result['error']['error'] ?? null,
$result['error']['error_description'] ?? null,
$result['data']['message'] ?? null,
$result['data']['description'] ?? null,
$result['data']['error'] ?? null,
$result['data']['error']['message'] ?? null,
$result['data']['error']['description'] ?? null,
$result['data']['error']['error'] ?? null,
$result['data']['error']['error_description'] ?? null,
$result['details']['message'] ?? null,
$result['details']['error'] ?? null,
$result['data']['raw_response'] ?? null,
] as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
@ -1603,10 +1671,246 @@ class Abha extends BaseController
$validStates = ['VALID', 'ACTIVE', 'VERIFIED', 'EXISTS', 'FOUND', 'SUCCESS'];
$status = in_array($statusRaw, $validStates, true) || $statusRaw === '' ? 'VALID' : $statusRaw;
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
$accounts = is_array($data['accounts'] ?? null) ? $data['accounts'] : [];
$account = isset($accounts[0]) && is_array($accounts[0])
? $accounts[0]
: (is_array($data['account'] ?? null) ? $data['account'] : []);
$txnId = trim((string) (
$result['txn_id']
?? $result['txnId']
?? $data['txn_id']
?? $data['txnId']
?? $data['transaction_id']
?? $data['transactionId']
?? $account['txn_id']
?? $account['txnId']
?? ''
));
$authMethods = is_array($result['auth_methods'] ?? null)
? $result['auth_methods']
: (is_array($result['authMethods'] ?? null)
? $result['authMethods']
: (is_array($data['authMethods'] ?? null) ? $data['authMethods'] : ($data['auth_methods'] ?? [])));
$blockedMethods = $data['blockedAuthMethods'] ?? $data['blocked_auth_methods'] ?? $account['blockedAuthMethods'] ?? $account['blocked_auth_methods'] ?? [];
$maskedMobile = $this->maskAbhaMobile((string) (
$data['mobile']
?? $data['mobileNumber']
?? $data['maskedMobile']
?? $data['masked_mobile']
?? $data['phone']
?? $account['masked_mobile']
?? $account['mobile']
?? $account['mobileNumber']
?? $account['maskedMobile']
?? $account['phone']
?? ''
));
return $this->response->setJSON([
'ok' => 1,
'status' => $status,
'raw_status' => $statusRaw,
'txn_id' => $txnId,
'auth_methods' => array_values(array_unique(array_map(static fn($method): string => strtoupper(trim((string) $method)), $authMethods))),
'blocked_auth_methods' => is_array($blockedMethods) ? array_values($blockedMethods) : [],
'masked_mobile' => $maskedMobile,
'account' => [
'name' => trim((string) ($account['name'] ?? $account['fullName'] ?? $account['full_name'] ?? $data['name'] ?? $data['fullName'] ?? $data['full_name'] ?? '')),
'abha_number' => trim((string) ($account['ABHANumber'] ?? $account['abhaNumber'] ?? $account['abha_id'] ?? $account['healthIdNumber'] ?? $data['ABHANumber'] ?? $data['healthIdNumber'] ?? $data['abha_id'] ?? '')),
'abha_address' => trim((string) ($account['abhaAddress'] ?? $account['preferredAddress'] ?? $account['preferredAbhaAddress'] ?? $account['abha_address'] ?? $data['abhaAddress'] ?? $data['preferredAddress'] ?? $data['preferredAbhaAddress'] ?? $data['abha_address'] ?? '')),
'masked_mobile' => $maskedMobile,
],
]);
}
private function maskAbhaMobile(string $value): string
{
$value = trim($value);
if ($value === '' || preg_match('/\*/', $value) === 1) {
return $value;
}
$digits = preg_replace('/\D/', '', $value);
if (strlen($digits) === 10) {
return substr($digits, 0, 2) . '*****' . substr($digits, -3);
}
return $value;
}
public function loginRequestOtp()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? ''));
$authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? '')));
if ($txnId === '' || ! in_array($authMethod, ['MOBILE_OTP', 'AADHAAR_OTP'], true)) {
return $this->response->setStatusCode(422)->setJSON(['ok' => 0, 'error_text' => 'Valid transaction and OTP method are required.']);
}
try {
$result = AbdmConnectorFactory::make()->abhaLoginRequestOtp([
'txn_id' => $txnId,
'auth_method' => $authMethod,
'abha_id' => trim((string) ($this->request->getPost('abha_id') ?? '')),
'abha_address' => trim((string) ($this->request->getPost('abha_address') ?? '')),
'scope' => trim((string) ($this->request->getPost('scope') ?? 'abha-login')),
]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(502)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
$httpCode = (int) ($result['http_code'] ?? 0);
if ($httpCode < 400 || $httpCode > 599) {
$httpCode = 502;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'error_text' => $this->extractBridgeErrorText($result, 'The ABDM Bridge could not send the OTP.'),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
$maskedMobile = trim((string) ($data['masked_mobile'] ?? $data['maskedMobile'] ?? $data['mobile'] ?? ''));
$deliveryMessage = trim((string) ($result['message'] ?? $data['message'] ?? ''));
if ($maskedMobile === '' && preg_match('/(\d{4})(?!.*\d)/', $deliveryMessage, $mobileEnding) === 1) {
$maskedMobile = '******' . $mobileEnding[1];
}
return $this->response->setJSON([
'ok' => 1,
'txn_id' => (string) ($result['txn_id'] ?? $result['txnId'] ?? $data['txn_id'] ?? $data['txnId'] ?? $txnId),
'auth_method' => $authMethod,
'masked_mobile' => $this->maskAbhaMobile($maskedMobile),
'resend_after' => max(60, (int) ($data['resend_after'] ?? $data['resendAfter'] ?? 60)),
]);
}
public function loginVerifyOtp()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? ''));
$authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? '')));
$otp = preg_replace('/\D/', '', trim((string) ($this->request->getPost('otp') ?? '')));
if ($txnId === '' || ! in_array($authMethod, ['MOBILE_OTP', 'AADHAAR_OTP'], true) || strlen($otp) !== 6) {
return $this->response->setStatusCode(422)->setJSON(['ok' => 0, 'error_text' => 'Transaction, OTP method, and 6-digit OTP are required.']);
}
try {
$result = AbdmConnectorFactory::make()->abhaLoginVerifyOtp([
'txn_id' => $txnId,
'auth_method' => $authMethod,
'otp' => $otp,
]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(502)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
$httpCode = (int) ($result['http_code'] ?? 0);
if ($httpCode < 400 || $httpCode > 599) {
$httpCode = 502;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'error_text' => $this->extractBridgeErrorText($result, 'OTP verification failed.'),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
$payload = is_array($result['data'] ?? null)
? array_merge($result, $result['data'])
: $result;
$profile = $this->pickGatewayAbhaProfile($payload);
$firstNonEmpty = static function (array $values): string {
foreach ($values as $value) {
if (is_scalar($value) && trim((string) $value) !== '') {
return trim((string) $value);
}
}
return '';
};
$abhaNumber = preg_replace('/\D/', '', $firstNonEmpty([$profile['ABHANumber'] ?? null, $profile['abhaNumber'] ?? null, $profile['abha_number'] ?? null, $profile['abha_id'] ?? null, $payload['ABHANumber'] ?? null, $payload['abhaNumber'] ?? null, $payload['abha_id'] ?? null]));
$abhaAddress = $firstNonEmpty([$profile['preferredAddress'] ?? null, $profile['preferredAbhaAddress'] ?? null, $profile['abhaAddress'] ?? null, $profile['abha_address'] ?? null, $payload['preferredAddress'] ?? null, $payload['abhaAddress'] ?? null, $payload['abha_address'] ?? null]);
$name = $this->extractAbhaProfileName($profile, $payload);
$mobile = $firstNonEmpty([$profile['mobile'] ?? null, $profile['mobileNumber'] ?? null, $profile['mobile_number'] ?? null, $payload['mobile'] ?? null, $payload['mobileNumber'] ?? null, $payload['mobile_number'] ?? null]);
$gender = $firstNonEmpty([$profile['gender'] ?? null, $payload['gender'] ?? null]);
$dob = $firstNonEmpty([$profile['dob'] ?? null, $profile['dateOfBirth'] ?? null, $profile['date_of_birth'] ?? null, $payload['dob'] ?? null, $payload['dateOfBirth'] ?? null, $payload['date_of_birth'] ?? null]);
if ($dob === '') {
$day = $firstNonEmpty([$profile['dayOfBirth'] ?? null, $profile['day_of_birth'] ?? null]);
$month = $firstNonEmpty([$profile['monthOfBirth'] ?? null, $profile['month_of_birth'] ?? null]);
$year = $firstNonEmpty([$profile['yearOfBirth'] ?? null, $profile['year_of_birth'] ?? null]);
if ($day !== '' && $month !== '' && $year !== '') {
$dob = sprintf('%04d-%02d-%02d', (int) $year, (int) $month, (int) $day);
}
}
$address = $firstNonEmpty([$profile['address'] ?? null, $profile['addressLine'] ?? null, $profile['address_line'] ?? null, $payload['address'] ?? null, $payload['addressLine'] ?? null, $payload['address_line'] ?? null]);
$district = $firstNonEmpty([$profile['districtName'] ?? null, $profile['district_name'] ?? null, $profile['district'] ?? null, $payload['districtName'] ?? null, $payload['district_name'] ?? null, $payload['district'] ?? null]);
$state = $firstNonEmpty([$profile['stateName'] ?? null, $profile['state_name'] ?? null, $profile['state'] ?? null, $payload['stateName'] ?? null, $payload['state_name'] ?? null, $payload['state'] ?? null]);
$zip = $firstNonEmpty([$profile['pinCode'] ?? null, $profile['pincode'] ?? null, $profile['pin'] ?? null, $payload['pinCode'] ?? null, $payload['pincode'] ?? null, $payload['pin'] ?? null]);
$photo = $firstNonEmpty([$profile['profilePhoto'] ?? null, $profile['profile_photo'] ?? null, $profile['photo'] ?? null, $payload['profilePhoto'] ?? null, $payload['profile_photo'] ?? null, $payload['photo'] ?? null]);
$cardData = $this->extractAbhaCardData($payload);
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
$candidates = $this->findMatchingCandidates($db, $fields, $name, $mobile, $gender, $dob, '', $abhaField, $abhaNumber);
return $this->response->setJSON([
'ok' => 1,
'need_confirmation' => true,
'abha_number' => $abhaNumber,
'abha_address' => $abhaAddress,
'name' => $name,
'mobile' => $mobile,
'gender' => $gender,
'dob' => $dob,
'address' => $address,
'district' => $district,
'state' => $state,
'zip' => $zip,
'photo' => $photo,
'verified_status' => 'VERIFIED',
'verification_type' => $authMethod,
'mobile_verified' => $authMethod === 'MOBILE_OTP' ? 1 : null,
'card_base64' => $cardData,
'card_content_type' => $this->resolveAbhaCardContentType($payload),
'candidates' => $candidates,
]);
}
private function extractAbhaCardData(array $payload): string
{
$sources = [$payload, $payload['account'] ?? [], $payload['profile'] ?? []];
foreach ($sources as $source) {
if (! is_array($source)) { continue; }
foreach (['card_base64', 'card_data', 'abhaCard', 'abha_card', 'official_card', 'cardData', 'card'] as $key) {
$value = $source[$key] ?? '';
if (is_string($value) && trim($value) !== '') { return trim($value); }
if (is_array($value)) {
foreach (['base64', 'data', 'card_base64', 'card_data'] as $nestedKey) {
if (is_string($value[$nestedKey] ?? null) && trim($value[$nestedKey]) !== '') {
return trim($value[$nestedKey]);
}
}
}
}
}
return '';
}
private function resolveAbhaCardContentType(array $payload): string
{
$format = strtolower(trim((string) ($payload['card_content_type'] ?? $payload['cardContentType'] ?? $payload['card_format'] ?? ($payload['account']['card_format'] ?? ''))));
if ($format === 'png') { return 'image/png'; }
if ($format === 'jpg' || $format === 'jpeg') { return 'image/jpeg'; }
if ($format === 'pdf') { return 'application/pdf'; }
return $format !== '' && str_contains($format, '/') ? $format : 'image/png';
}
}

View File

@ -12530,7 +12530,7 @@ class Medical extends BaseController
: '';
$whereSql = implode(' AND ', $conditions);
$sql = "SELECT p.item_name, p.genericname,
$sql = "SELECT p.item_name, p.formulation, p.genericname,
IFNULL(s.packing,p.packing) AS packing,
s.batch_no,
s.expiry_date,
@ -12546,7 +12546,7 @@ class Medical extends BaseController
JOIN purchase_invoice_item s ON p.id=s.item_code
JOIN purchase_invoice m ON s.purchase_id=m.id
WHERE {$whereSql}
GROUP BY p.item_name, p.genericname, IFNULL(s.packing,p.packing), s.batch_no, s.expiry_date, s.mrp, s.purchase_unit_rate, s.CGST_per
GROUP BY p.item_name, p.formulation, p.genericname, IFNULL(s.packing,p.packing), s.batch_no, s.expiry_date, s.mrp, s.purchase_unit_rate, s.CGST_per
{$havingSql}
ORDER BY p.item_name, s.batch_no";
@ -12562,9 +12562,10 @@ class Medical extends BaseController
foreach ($stockList as $row) {
$stockCost = (float) ($row->stock_cost ?? 0);
$totalStockValue += $stockCost;
$medicineName = trim((string) ($row->formulation ?? '') . ' ' . (string) ($row->item_name ?? ''));
$content .= '<tr>'
. '<td>' . esc((string) ($row->item_name ?? '')) . '</td>'
. '<td>' . esc($medicineName) . '</td>'
. '<td>' . esc((string) ($row->genericname ?? '')) . '</td>'
. '<td>' . esc(number_format((float) ($row->mrp ?? 0), 2, '.', '')) . '</td>'
. '<td>' . esc((string) ($row->batch_no ?? '')) . '</td>'

View File

@ -3186,89 +3186,7 @@ class Patient extends BaseController
$patient = $this->findPatientForAbhaProfile($requestedPid, $abhaDigits, $gatewayMobile);
$patientId = (int) ($patient['id'] ?? 0);
$before = $patient !== null ? $this->buildPatientSnapshot($patient) : null;
$photoMeta = [
'saved' => false,
'path' => '',
'error' => '',
];
if ($patientId > 0 && $this->db->tableExists('patient_master')) {
$updates = [];
if ($abhaDigits !== '') {
$abhaField = $this->resolvePatientAbhaIdField();
if ($abhaField !== null) {
$updates[$abhaField] = $abhaDigits;
}
}
$pmFields = $this->db->getFieldNames('patient_master') ?? [];
if ($gatewayMobile !== '' && in_array('mphone1', $pmFields, true)) {
$updates['mphone1'] = preg_replace('/\D/', '', $gatewayMobile);
}
$dobDb = $this->normalizeGatewayDobToDb($gatewayDob);
if ($dobDb !== '' && in_array('dob', $pmFields, true)) {
$updates['dob'] = $dobDb;
if (in_array('estimate_dob', $pmFields, true)) {
$updates['estimate_dob'] = 0;
}
}
$genderDb = $this->toPatientGenderDbValue($gatewayGender);
if ($genderDb !== null && in_array('gender', $pmFields, true)) {
$updates['gender'] = $genderDb;
}
if ($gatewayAddress !== '' && in_array('add1', $pmFields, true)) {
$updates['add1'] = $gatewayAddress;
}
if ($gatewayCity !== '' && in_array('city', $pmFields, true)) {
$updates['city'] = $gatewayCity;
}
if ($gatewayDistrictName !== '' && in_array('district', $pmFields, true)) {
$updates['district'] = $gatewayDistrictName;
}
if ($gatewayStateName !== '' && in_array('state', $pmFields, true)) {
$updates['state'] = $gatewayStateName;
}
if ($gatewayZip !== '' && in_array('zip', $pmFields, true)) {
$updates['zip'] = $gatewayZip;
}
if ($gatewayEmail !== '' && in_array('email1', $pmFields, true)) {
$updates['email1'] = $gatewayEmail;
}
if ($verifiedStatus !== '' && in_array('abha_verified_status', $pmFields, true)) {
$updates['abha_verified_status'] = $verifiedStatus;
}
if ($verificationType !== '' && in_array('abha_verification_type', $pmFields, true)) {
$updates['abha_verification_type'] = $verificationType;
}
if ($kycVerified !== null && in_array('abha_kyc_verified', $pmFields, true)) {
$updates['abha_kyc_verified'] = (int) ((string) $kycVerified === '1' || (string) $kycVerified === 'true' || (string) $kycVerified === 'yes');
}
if ($mobileVerified !== null && in_array('abha_mobile_verified', $pmFields, true)) {
$updates['abha_mobile_verified'] = (int) ((string) $mobileVerified === '1' || (string) $mobileVerified === 'true' || (string) $mobileVerified === 'yes');
}
if ($abhaAddress !== '' && in_array('abha_address', $pmFields, true)) {
$updates['abha_address'] = $abhaAddress;
}
if (in_array('abdm_linked_at', $pmFields, true) && ($abhaDigits !== '' || $abhaAddress !== '')) {
$updates['abdm_linked_at'] = date('Y-m-d H:i:s');
}
if ($updates !== []) {
$this->db->table('patient_master')->where('id', $patientId)->update($updates);
}
if ($gatewayPhoto !== '') {
$photoMeta = $this->saveGatewayProfilePhotoToPatient($patientId, $gatewayPhoto);
}
}
$afterPatient = null;
if ($patientId > 0 && $this->db->tableExists('patient_master')) {
$afterPatient = $this->db->table('patient_master')->where('id', $patientId)->get()->getRowArray();
}
$after = $afterPatient !== null ? $this->buildPatientSnapshot($afterPatient) : $before;
$after = $before;
$result['gateway_user'] = [
'name' => $gatewayName,
@ -3324,7 +3242,11 @@ class Patient extends BaseController
? $this->normalizeGenderForCompare((string) ($after['gender'] ?? '')) === $this->normalizeGenderForCompare($gatewayGender)
: null,
],
'photo_update' => $photoMeta,
'photo_update' => [
'saved' => false,
'path' => '',
'error' => '',
],
];
return $result;

View File

@ -37,6 +37,22 @@ interface AbdmConnectorInterface
*/
public function validateAbha(string $abhaId, array $fullPayload = []): array;
/**
* Request an OTP for an ABHA account returned by validateAbha().
*
* @param array<string, mixed> $payload Must contain the search txn_id and
* selected MOBILE_OTP or AADHAAR_OTP method.
*/
public function abhaLoginRequestOtp(array $payload): array;
/**
* Verify an account-bound ABHA login OTP and return the normalized profile
* plus the official ABHA card payload when available.
*
* @param array<string, mixed> $payload Must contain txn_id, auth_method, and otp.
*/
public function abhaLoginVerifyOtp(array $payload): array;
// -------------------------------------------------------------------------
// Consent
// -------------------------------------------------------------------------

View File

@ -55,6 +55,16 @@ class DirectAbdmConnector implements AbdmConnectorInterface
);
}
public function abhaLoginRequestOtp(array $payload): array
{
throw new \RuntimeException('DirectAbdmConnector::abhaLoginRequestOtp() not implemented.');
}
public function abhaLoginVerifyOtp(array $payload): array
{
throw new \RuntimeException('DirectAbdmConnector::abhaLoginVerifyOtp() not implemented.');
}
// -------------------------------------------------------------------------
// Consent
// -------------------------------------------------------------------------

View File

@ -41,6 +41,16 @@ class DreamsoftConnector implements AbdmConnectorInterface
return ['ok' => 1, 'queue_id' => $queueId, 'status' => 'queued'];
}
public function abhaLoginRequestOtp(array $payload): array
{
throw new \RuntimeException('ABHA login OTP requires the synchronous e-Atria Bridge connector.');
}
public function abhaLoginVerifyOtp(array $payload): array
{
throw new \RuntimeException('ABHA login OTP verification requires the synchronous e-Atria Bridge connector.');
}
// -------------------------------------------------------------------------
// Consent
// -------------------------------------------------------------------------

View File

@ -220,7 +220,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
}
$lowerKey = strtolower($key);
if (in_array($lowerKey, ['token', 'refresh_token', 'refreshtoken', 'authorization', 'auth', 'otp'], true)) {
if (in_array($lowerKey, [
'token', 'refresh_token', 'refreshtoken', 'authorization', 'auth', 'otp',
'loginid', 'login_id', 'mobile', 'mobilenumber', 'mobile_number', 'phone', 'phone_number',
], true)) {
return '[REDACTED]';
}
@ -544,6 +547,11 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
'type' => $inputType,
'value' => $inputValue,
];
if ($inputType === 'abha-address') {
$body['abha_address'] = $inputValue;
} else {
$body['abha_id'] = $inputValue;
}
if ($this->hfrId !== '') {
$body['hfr_id'] = $this->hfrId;
}
@ -569,8 +577,20 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
}
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
$nestedResult = is_array($result['result'] ?? null) ? $result['result'] : [];
$accounts = is_array($data['accounts'] ?? null) ? $data['accounts'] : [];
$firstAccount = isset($accounts[0]) && is_array($accounts[0]) ? $accounts[0] : [];
$firstAccount = isset($accounts[0]) && is_array($accounts[0])
? $accounts[0]
: (is_array($data['account'] ?? null) ? $data['account'] : []);
$txnId = trim((string) (
$result['txn_id'] ?? $result['txnId']
?? $data['txn_id'] ?? $data['txnId']
?? $data['transaction_id'] ?? $data['transactionId']
?? $nestedResult['txn_id'] ?? $nestedResult['txnId']
?? $nestedResult['transaction_id'] ?? $nestedResult['transactionId']
?? $firstAccount['txn_id'] ?? $firstAccount['txnId'] ?? ''
));
$status = '';
foreach ([
@ -592,9 +612,14 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$authMethods = [];
foreach ([
$result['authMethods'] ?? null,
$result['auth_methods'] ?? null,
$data['authMethods'] ?? null,
$data['auth_methods'] ?? null,
$data['availableAuthMethods'] ?? null,
$nestedResult['authMethods'] ?? null,
$nestedResult['auth_methods'] ?? null,
$nestedResult['availableAuthMethods'] ?? null,
$firstAccount['authMethods'] ?? null,
$firstAccount['auth_methods'] ?? null,
$firstAccount['availableAuthMethods'] ?? null,
@ -613,11 +638,117 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$result['auth_methods'] = $authMethods;
}
if ($txnId !== '') {
$result['txn_id'] = $txnId;
}
$maskedMobile = trim((string) (
$result['masked_mobile'] ?? $result['maskedMobile']
?? $data['masked_mobile'] ?? $data['maskedMobile'] ?? $data['mobile']
?? $data['mobile_number'] ?? $data['phone'] ?? $data['phone_number']
?? $firstAccount['masked_mobile'] ?? $firstAccount['maskedMobile'] ?? $firstAccount['mobile']
?? $firstAccount['mobile_number'] ?? $firstAccount['phone'] ?? $firstAccount['phone_number'] ?? ''
));
if ($maskedMobile !== '') {
$result['masked_mobile'] = $maskedMobile;
}
// HMS callers expect a status-like field; treat successful search as VALID when absent.
$result['status'] = $status !== '' ? $status : 'VALID';
return $result;
}
public function abhaLoginRequestOtp(array $payload): array
{
$authMethod = strtoupper((string) ($payload['auth_method'] ?? $payload['authMethod'] ?? ''));
$body = [
'txn_id' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''),
'auth_method' => $authMethod,
];
if (($payload['abha_id'] ?? '') !== '') { $body['abha_id'] = (string) $payload['abha_id']; }
if (($payload['abha_address'] ?? '') !== '') { $body['abha_address'] = (string) $payload['abha_address']; }
if ($this->hfrId !== '') {
$body['hfr_id'] = $this->hfrId;
}
return $this->post('/v3/abha/login/request-otp', $body);
}
public function abhaLoginVerifyOtp(array $payload): array
{
$authMethod = strtoupper((string) ($payload['auth_method'] ?? $payload['authMethod'] ?? ''));
$body = [
'txn_id' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''),
'txnId' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''),
'auth_method' => $authMethod,
'otp' => (string) ($payload['otp'] ?? ''),
'scope' => $authMethod === 'AADHAAR_OTP'
? ['abha-login', 'aadhaar-verify']
: ['abha-login', 'mobile-verify'],
];
if (isset($payload['authData']) && is_array($payload['authData'])) {
$body['authData'] = $payload['authData'];
}
if ($this->hfrId !== '') {
$body['hfr_id'] = $this->hfrId;
}
$result = $this->post('/v3/abha/login/verify-otp', $body);
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
return $result;
}
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
$account = is_array($result['account'] ?? null)
? $result['account']
: (is_array($data['account'] ?? null) ? $data['account'] : []);
$abhaNumber = trim((string) ($account['ABHANumber'] ?? $account['abhaNumber'] ?? $account['abha_id'] ?? $data['ABHANumber'] ?? $data['abhaNumber'] ?? $data['abha_id'] ?? ''));
$abhaAddress = trim((string) ($account['abhaAddress'] ?? $account['preferredAddress'] ?? $account['abha_address'] ?? $data['abhaAddress'] ?? $data['preferredAddress'] ?? $data['abha_address'] ?? ''));
$extractCard = static function (array $source): string {
foreach (['card_base64', 'card_data', 'abhaCard', 'abha_card', 'official_card', 'cardData', 'card'] as $key) {
$value = $source[$key] ?? '';
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
if (is_array($value)) {
foreach (['base64', 'data', 'card_base64', 'card_data'] as $nestedKey) {
if (is_string($value[$nestedKey] ?? null) && trim($value[$nestedKey]) !== '') {
return trim($value[$nestedKey]);
}
}
}
}
return '';
};
$card = $extractCard($result) ?: $extractCard($data) ?: $extractCard($account);
if ($card === '' && ($abhaNumber !== '' || $abhaAddress !== '')) {
$cardResult = $this->get('/v3/abha/card', array_filter([
'abha_number' => $abhaNumber,
'abha_address' => $abhaAddress,
], static fn($value): bool => trim((string) $value) !== ''));
if (! empty($cardResult['ok']) && (int) $cardResult['ok'] === 1) {
$cardData = is_array($cardResult['data'] ?? null) ? $cardResult['data'] : $cardResult;
$card = $extractCard($cardData);
if ($card !== '') {
$result['card_base64'] = $card;
$result['card_content_type'] = $this->resolveCardContentType($cardData);
}
}
}
return $result;
}
private function resolveCardContentType(array $payload): string
{
$format = strtolower(trim((string) ($payload['card_content_type'] ?? $payload['content_type'] ?? $payload['card_format'] ?? '')));
if ($format === 'png') { return 'image/png'; }
if ($format === 'jpg' || $format === 'jpeg') { return 'image/jpeg'; }
if ($format === 'pdf') { return 'application/pdf'; }
return $format !== '' && str_contains($format, '/') ? $format : 'image/png';
}
// -------------------------------------------------------------------------
// M1 ABHA OTP Flows
// -------------------------------------------------------------------------

View File

@ -41,6 +41,7 @@
.abha-panel { border-left:3px solid #007bff; padding-left:16px; }
</style>
<?= view('partials/abha_patient_match_modal') ?>
<?= view('partials/abha_verify_modal') ?>
<section class="content">
<div class="row">
<div class="col-md-12">
@ -1362,7 +1363,7 @@
}
var methodMeta = {
number: { icon: '🆔', title: 'By ABHA Number/Address', sub: 'Validate then OTP verify' },
number: { icon: '🆔', title: 'By ABHA Number/Address', sub: 'Secure modal verification' },
qr: { icon: '📷', title: 'Scan Patient ABHA QR', sub: 'Camera → ABHA extracted → OTP verify' },
facility: { icon: '🏥', title: 'Scan Facility QR', sub: 'Patient scans via ABHA app' },
mobile: { icon: '📱', title: 'By Mobile OTP', sub: 'OTP to ABHA-linked mobile' }
@ -1380,11 +1381,26 @@
$('.abhareg-panel').addClass('d-none');
$('#abhareg_panel_' + method).removeClass('d-none');
$('#abhareg_result').addClass('d-none');
if (method === 'qr') { initQrScanner(); }
if (method === 'number') {
$('#abhareg_panel_number').addClass('d-none');
openAbhaAccountVerification('', null);
} else if (method === 'qr') { initQrScanner(); }
else { stopQrScanner(); }
if (method === 'facility') { loadFacilityQr(false); }
};
function openAbhaAccountVerification(identifier, lookupResponse) {
if (!window.AbhaVerifyModal) {
regAlert('abhareg_num_stepA_alert', 'danger', 'ABHA verification modal is unavailable. Reload the page and try again.');
return;
}
window.AbhaVerifyModal.open(identifier || '', lookupResponse || null, function(profile) {
window.AbhaPatientMatchModal.open(profile, profile.candidates || [], function(finalResponse) {
showRegResult(finalResponse);
});
});
}
window.abhaRegReset = function() {
regTxnId = null;
stopQrScanner();
@ -1577,9 +1593,7 @@
var numMatch = abha.match(/\d{14}/);
if (numMatch) { abha = numMatch[0]; }
$('#abhareg_qr_result').html('<div class="alert alert-success py-2"><i class="bi bi-qr-code me-2"></i>QR scanned: <strong>' + abha + '</strong></div>');
abhaRegSelectMethod('number');
$('#abhareg_abha_input').val(abha);
$('#abhareg_validate_btn').trigger('click');
openAbhaAccountVerification(abha, null);
},
function() {}
).then(function() {
@ -1658,7 +1672,7 @@
$('#abhareg_validate_btn').prop('disabled', false);
if (resp && resp.ok == 1 && resp.status === 'VALID') {
$('#abhareg_abha_status').html('<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>VALID</span>');
$('#abhareg_goto_stepB').removeClass('d-none');
openAbhaAccountVerification(abha, resp);
} else {
var msg = pickApiMessage(resp, (resp && resp.status ? 'Status: ' + resp.status : 'ABHA not found or invalid.'));
regAlert('abhareg_num_stepA_alert', 'danger', msg);

View File

@ -23,7 +23,7 @@
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-people-fill me-2"></i>Possible Matching Patients Found</h5>
<h5 class="modal-title"><i class="bi bi-people-fill me-2"></i>Patient Already Exists</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@ -63,6 +63,7 @@
<div id="abhaMatch_empty" class="alert alert-warning py-2 small d-none">
No matching patient found in HMS by name, age or gender. You can create a new patient record.
</div>
<div class="small text-muted mt-2"><i class="bi bi-info-circle me-1"></i>The verified ABHA identity is linked in both existing-patient actions. Keep Existing preserves HMS demographics; Update Details applies the latest ABHA profile.</div>
</div>
</div>
</div>
@ -70,9 +71,10 @@
<button type="button" class="btn btn-outline-secondary" id="abhaMatch_create_new_btn">
<i class="bi bi-person-plus me-1"></i>Create New Patient
</button>
<button type="button" class="btn btn-success" id="abhaMatch_update_existing_btn" disabled>
<i class="bi bi-link-45deg me-1"></i>Update Selected Patient
</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary" id="abhaMatch_keep_existing_btn" disabled><i class="bi bi-person-check me-1"></i>Keep Existing</button>
<button type="button" class="btn btn-primary" id="abhaMatch_update_existing_btn" disabled><i class="bi bi-arrow-repeat me-1"></i>Update Details</button>
</div>
</div>
</div>
</div>
@ -164,6 +166,7 @@ window.AbhaPatientMatchModal = (function () {
+ badge(m.aadhaar, 'Aadhaar')
+ ' </div>'
+ ' <div class="small text-muted mt-1">DOB: ' + esc(c.dob || '—') + ' | Mobile: ' + esc(maskMobile(c.mobile) || '—') + '</div>'
+ ' <div class="small text-muted mt-1">Address: ' + esc(c.address || '—') + '</div>'
+ conflictNote
+ ' </div>'
+ ' <div class="form-check">'
@ -181,18 +184,19 @@ window.AbhaPatientMatchModal = (function () {
$('.abhaMatch-candidate').removeClass('border-success bg-success-subtle');
$('.abhaMatch-candidate[data-id="' + id + '"]').addClass('border-success bg-success-subtle');
$('.abhaMatch-radio[value="' + id + '"]').prop('checked', true);
$('#abhaMatch_update_existing_btn').prop('disabled', !id);
$('#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', !id);
}
function submitConfirm(action, patientId, $btn) {
function submitConfirm(action, patientId, updateMode, $btn) {
$('#abhaMatch_alert').empty();
var origHtml = $btn.html();
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Saving…');
$('#abhaMatch_create_new_btn,#abhaMatch_update_existing_btn').prop('disabled', true);
$('#abhaMatch_create_new_btn,#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', true);
var c = csrfPair();
var payload = {
action: action,
update_mode: updateMode || 'update',
patient_id: patientId || 0,
abha_number: _profile.abha_number || '',
name: _profile.name || '',
@ -216,9 +220,8 @@ window.AbhaPatientMatchModal = (function () {
$.post('<?= base_url('abha/create/confirm_patient') ?>', payload, function (resp) {
updateCsrf(resp);
$btn.prop('disabled', false).html(origHtml);
$('#abhaMatch_create_new_btn,#abhaMatch_update_existing_btn').prop('disabled', !_selectedId && $btn.attr('id') === 'abhaMatch_update_existing_btn');
$('#abhaMatch_create_new_btn').prop('disabled', false);
$('#abhaMatch_update_existing_btn').prop('disabled', !_selectedId);
$('#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', !_selectedId);
if (!resp || resp.ok != 1) {
$('#abhaMatch_alert').html(alertHtml('danger', esc((resp && resp.error_text) || 'Failed to save patient link.')));
@ -231,7 +234,7 @@ window.AbhaPatientMatchModal = (function () {
}, 'json').fail(function () {
$btn.prop('disabled', false).html(origHtml);
$('#abhaMatch_create_new_btn').prop('disabled', false);
$('#abhaMatch_update_existing_btn').prop('disabled', !_selectedId);
$('#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', !_selectedId);
$('#abhaMatch_alert').html(alertHtml('danger', 'Server error while saving. Please try again.'));
});
}
@ -246,11 +249,15 @@ window.AbhaPatientMatchModal = (function () {
});
$('#abhaMatch_create_new_btn').on('click', function () {
submitConfirm('new', 0, $(this));
submitConfirm('new', 0, 'update', $(this));
});
$('#abhaMatch_keep_existing_btn').on('click', function () {
if (!_selectedId) { return; }
submitConfirm('existing', _selectedId, 'keep', $(this));
});
$('#abhaMatch_update_existing_btn').on('click', function () {
if (!_selectedId) { return; }
submitConfirm('existing', _selectedId, $(this));
submitConfirm('existing', _selectedId, 'update', $(this));
});
});
@ -260,7 +267,7 @@ window.AbhaPatientMatchModal = (function () {
_selectedId = 0;
_onResolved = onResolved;
$('#abhaMatch_alert').empty();
$('#abhaMatch_update_existing_btn').prop('disabled', true);
$('#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', true);
renderProfile(_profile);
renderCandidates(candidates || []);
if (_bsModal) { _bsModal.show(); } else { $modalEl.modal('show'); }

View File

@ -0,0 +1,287 @@
<style>
.abha-verify-modal .modal-content { border:0; border-radius:12px; overflow:hidden; }
.abha-verify-modal .modal-header { border-bottom:1px solid #e5eaf1; padding:18px 22px; }
.abha-verify-modal .modal-body { padding:22px; }
.abha-verify-steps { display:flex; justify-content:center; align-items:center; margin-bottom:24px; }
.abha-verify-step { display:flex; align-items:center; color:#8792a3; }
.abha-verify-step:not(:last-child)::after { content:""; width:44px; height:2px; background:#dce3ec; margin:0 7px; }
.abha-verify-step.done:not(:last-child)::after { background:#356cf4; }
.abha-verify-step span { width:30px; height:30px; display:grid; place-items:center; border-radius:50%; background:#eef2f6; font-weight:700; }
.abha-verify-step.active span { color:#1748ce; background:#fff; border:2px solid #356cf4; box-shadow:0 0 0 4px #edf2ff; }
.abha-verify-step.done span { color:#fff; background:#356cf4; }
.abha-auth-option { display:block; border:1px solid #dce3ec; border-radius:8px; padding:14px; cursor:pointer; }
.abha-auth-option:has(input:checked) { border-color:#356cf4; background:#edf2ff; box-shadow:0 0 0 1px #356cf4; }
.abha-profile-list { margin:0; }
.abha-profile-list>div { display:grid; grid-template-columns:150px minmax(0,1fr); gap:14px; padding:10px 0; border-bottom:1px solid #edf0f4; }
.abha-profile-list dt { color:#6b7688; font-weight:500; }
.abha-profile-list dd { margin:0; text-align:right; font-weight:600; overflow-wrap:anywhere; }
.abha-card-preview { min-height:230px; display:grid; place-items:center; border:1px solid #dce3ec; background:#f8fafc; }
.abha-card-preview img { max-width:100%; max-height:360px; object-fit:contain; }
@media (max-width:575.98px) { .abha-verify-modal .modal-body{padding:16px}.abha-verify-step:not(:last-child)::after{width:22px}.abha-profile-list>div{grid-template-columns:110px minmax(0,1fr)} }
</style>
<div class="modal fade abha-verify-modal" id="abhaVerifyModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<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-shield-check text-primary me-2"></i>Verify ABHA Account</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="abha-verify-steps" aria-label="Verification progress">
<div class="abha-verify-step active" data-step="1"><span>1</span></div>
<div class="abha-verify-step" data-step="2"><span>2</span></div>
<div class="abha-verify-step" data-step="3"><span>3</span></div>
<div class="abha-verify-step" data-step="4"><span>4</span></div>
</div>
<div id="abhaVerifyAlert"></div>
<section id="abhaVerifyStep1">
<label class="form-label fw-semibold" for="abhaVerifyIdentifier">ABHA Number or ABHA Address</label>
<div class="input-group input-group-lg">
<span class="input-group-text"><i class="bi bi-person-vcard"></i></span>
<input type="text" class="form-control" id="abhaVerifyIdentifier" placeholder="91-0000-0000-0000 or username@abdm" autocomplete="off">
<button type="button" class="btn btn-primary" id="abhaVerifyLookupBtn"><i class="bi bi-search me-1"></i>Find Account</button>
</div>
<div class="form-text">Enter the patient's ABHA identity. A mobile number is not required for account lookup.</div>
</section>
<section id="abhaVerifyStep2" class="d-none">
<div class="mb-3"><strong id="abhaVerifyFoundName"></strong><div class="text-muted small" id="abhaVerifyFoundIdentity"></div></div>
<p class="mb-2">Choose where ABDM should send the OTP.</p>
<div id="abhaVerifyAuthOptions" class="d-grid gap-2"></div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" id="abhaVerifyBackLookupBtn"><i class="bi bi-arrow-left me-1"></i>Back</button>
<button type="button" class="btn btn-primary" id="abhaVerifySendOtpBtn"><i class="bi bi-send me-1"></i>Send OTP</button>
</div>
</section>
<section id="abhaVerifyStep3" class="d-none">
<p class="text-muted mb-2" id="abhaVerifyOtpHint"></p>
<label class="form-label fw-semibold" for="abhaVerifyOtp">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="abhaVerifyOtp" maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="Enter OTP">
</div>
<div class="d-flex justify-content-between align-items-center mt-3 gap-2">
<button type="button" class="btn btn-link px-0" id="abhaVerifyResendBtn" disabled>Resend OTP in 60s</button>
<button type="button" class="btn btn-primary" id="abhaVerifyOtpBtn"><i class="bi bi-patch-check me-1"></i>Verify OTP</button>
</div>
</section>
<section id="abhaVerifyStep4" 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="abhaVerifyPhoto" 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="abhaVerifyProfileName"></h4><div class="text-muted" id="abhaVerifyProfileAddress"></div></div>
</div>
<dl class="abha-profile-list">
<div><dt>ABHA Number</dt><dd id="abhaVerifyProfileNumber">-</dd></div>
<div><dt>ABHA Address</dt><dd id="abhaVerifyProfileId">-</dd></div>
<div><dt>Date of Birth</dt><dd id="abhaVerifyProfileDob">-</dd></div>
<div><dt>Gender</dt><dd id="abhaVerifyProfileGender">-</dd></div>
<div><dt>Mobile</dt><dd id="abhaVerifyProfileMobile">-</dd></div>
<div><dt>Address</dt><dd id="abhaVerifyProfileFullAddress">-</dd></div>
</dl>
</div>
<div class="col-lg-6">
<div class="abha-card-preview" id="abhaVerifyCardWrap"><div class="text-center text-muted"><i class="bi bi-card-image fs-1 d-block mb-2"></i><span id="abhaVerifyCardMessage">Official ABHA card was not returned by the Bridge.</span></div></div>
<a class="btn btn-outline-primary w-100 mt-2 d-none" id="abhaVerifyDownloadCard" download="ABHA-card"><i class="bi bi-download me-1"></i>Download ABHA Card</a>
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<button type="button" class="btn btn-success" id="abhaVerifyCompareBtn"><i class="bi bi-people me-1"></i>Compare with HMS Patients</button>
</div>
</section>
</div>
</div>
</div>
</div>
<script>
window.AbhaVerifyModal = (function () {
'use strict';
var modal;
var lookupResponse = null;
var verifiedProfile = null;
var onVerified = null;
var resendTimer = null;
var resendRemaining = 0;
function csrf() {
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 apiMessage(response, fallback) {
return response && (response.error_text || response.message) ? escapeHtml(response.error_text || response.message) : fallback;
}
function alertBox(type, message) { $('#abhaVerifyAlert').html(message ? '<div class="alert alert-' + type + ' py-2">' + message + '</div>' : ''); }
function showStep(step) {
$('#abhaVerifyStep1,#abhaVerifyStep2,#abhaVerifyStep3,#abhaVerifyStep4').addClass('d-none');
$('#abhaVerifyStep' + step).removeClass('d-none');
$('.abha-verify-step').each(function () {
var itemStep = Number($(this).data('step'));
$(this).toggleClass('active', itemStep === step).toggleClass('done', itemStep < step);
});
alertBox('', '');
}
function stopTimer() {
if (resendTimer) window.clearInterval(resendTimer);
resendTimer = null;
}
function startResendTimer(seconds) {
stopTimer();
resendRemaining = Math.max(60, Number(seconds) || 60);
var button = $('#abhaVerifyResendBtn').prop('disabled', true);
function tick() {
if (resendRemaining <= 0) {
stopTimer();
button.prop('disabled', false).text('Resend OTP');
return;
}
button.text('Resend OTP in ' + resendRemaining + 's');
resendRemaining--;
}
tick();
resendTimer = window.setInterval(tick, 1000);
}
function authLabel(method) {
if (method === 'AADHAAR_OTP') return { title:'Aadhaar OTP', detail:'OTP to the mobile linked with Aadhaar', icon:'bi-fingerprint' };
return { title:'ABHA Registered Mobile OTP', detail:'OTP to the mobile registered with this ABHA account', icon:'bi-phone' };
}
function renderAuthMethods(response) {
var methods = (response.auth_methods || []).filter(function (method) { return method === 'MOBILE_OTP' || method === 'AADHAAR_OTP'; });
var blocked = response.blocked_auth_methods || [];
var availableMethods = methods.filter(function (method) { return blocked.indexOf(method) === -1; });
var html = '';
methods.forEach(function (method) {
var label = authLabel(method);
var disabled = blocked.indexOf(method) !== -1;
var checked = availableMethods[0] === method;
var mobile = response.masked_mobile || (response.account && response.account.masked_mobile) || '';
var destination = mobile ? ' (' + escapeHtml(mobile) + ')' : '';
html += '<label class="abha-auth-option' + (disabled ? ' opacity-50' : '') + '"><span class="d-flex align-items-center gap-3"><input class="form-check-input mt-0" type="radio" name="abhaVerifyAuth" value="' + method + '" ' + (checked ? 'checked' : '') + ' ' + (disabled ? 'disabled' : '') + '><i class="bi ' + label.icon + ' fs-4 text-primary"></i><span><strong>' + label.title + destination + '</strong><small class="d-block text-muted">' + label.detail + '</small></span></span></label>';
});
$('#abhaVerifyAuthOptions').html(html || '<div class="alert alert-warning mb-0">This ABHA account did not return a supported OTP method. The Bridge must return MOBILE_OTP or AADHAAR_OTP.</div>');
$('#abhaVerifySendOtpBtn').prop('disabled', !availableMethods.length);
}
function lookup() {
var identifier = $('#abhaVerifyIdentifier').val().trim();
if (!identifier) { alertBox('warning', 'Enter an ABHA number or ABHA address.'); return; }
var button = $('#abhaVerifyLookupBtn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Looking up');
$.post('<?= base_url('abha/register/validate') ?>', { abha_id:identifier, '<?= csrf_token() ?>':csrf() }, function (response) {
button.prop('disabled', false).html('<i class="bi bi-search me-1"></i>Find Account');
if (!response || response.ok != 1 || response.status !== 'VALID') { alertBox('danger', apiMessage(response, 'ABHA account was not found.')); return; }
if (!response.txn_id) { alertBox('warning', 'The Bridge found this ABHA but did not return the login transaction ID. Update /v3/abha/login/search before OTP verification can continue.'); return; }
lookupResponse = response;
var account = response.account || {};
$('#abhaVerifyFoundName').text(account.name || 'ABHA account found');
$('#abhaVerifyFoundIdentity').text(account.abha_address || account.abha_number || identifier);
renderAuthMethods(response);
showStep(2);
}, 'json').fail(function (xhr) {
button.prop('disabled', false).html('<i class="bi bi-search me-1"></i>Find Account');
alertBox('danger', apiMessage(xhr.responseJSON, 'Unable to look up the ABHA account.'));
});
}
function requestOtp() {
var method = $('input[name="abhaVerifyAuth"]:checked').val();
if (!method || !lookupResponse) return;
var account = lookupResponse.account || {};
var button = $('#abhaVerifySendOtpBtn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Sending');
$.post('<?= base_url('abha/register/login/request-otp') ?>', { txn_id:lookupResponse.txn_id, auth_method:method, abha_id:account.abha_number || '', abha_address:account.abha_address || '', '<?= 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.')); return; }
lookupResponse.txn_id = response.txn_id || lookupResponse.txn_id;
lookupResponse.auth_method = method;
var label = authLabel(method);
$('#abhaVerifyOtpHint').text('OTP sent using ' + label.title + (response.masked_mobile ? ' (' + response.masked_mobile + ')' : '') + '.');
$('#abhaVerifyOtp').val('');
showStep(3);
startResendTimer(response.resend_after);
$('#abhaVerifyOtp').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, 'The ABDM Bridge does not yet support account-bound OTP request.'));
});
}
function profilePhoto(value) { return !value ? '' : (String(value).indexOf('data:') === 0 ? value : 'data:image/jpeg;base64,' + value); }
function formatAbha(value) { return String(value || '').replace(/(\d{2})(\d{4})(\d{4})(\d{4})/, '$1-$2-$3-$4'); }
function renderProfile(profile) {
verifiedProfile = profile;
$('#abhaVerifyProfileName').text(profile.name || '-');
$('#abhaVerifyProfileAddress').text(profile.abha_address || '-');
$('#abhaVerifyProfileNumber').text(formatAbha(profile.abha_number) || '-');
$('#abhaVerifyProfileId').text(profile.abha_address || '-');
$('#abhaVerifyProfileDob').text(profile.dob || '-');
$('#abhaVerifyProfileGender').text(profile.gender || '-');
$('#abhaVerifyProfileMobile').text(profile.mobile || '-');
$('#abhaVerifyProfileFullAddress').text([profile.address, profile.district, profile.state, profile.zip].filter(Boolean).join(', ') || '-');
var photo = profilePhoto(profile.photo);
$('#abhaVerifyPhoto').toggleClass('d-none', !photo).attr('src', photo || '');
var card = profile.card_base64 || '';
if (card) {
var cardSrc = String(card).indexOf('data:') === 0 ? card : 'data:' + (profile.card_content_type || 'image/png') + ';base64,' + card;
$('#abhaVerifyCardWrap').html('<img src="' + cardSrc + '" alt="Official ABHA card">');
$('#abhaVerifyDownloadCard').attr('href', cardSrc).removeClass('d-none');
} else {
$('#abhaVerifyCardWrap').html('<div class="text-center text-muted"><i class="bi bi-card-image fs-1 d-block mb-2"></i>Official ABHA card was not returned by the Bridge.</div>');
$('#abhaVerifyDownloadCard').addClass('d-none');
}
showStep(4);
}
function verifyOtp() {
var otp = $('#abhaVerifyOtp').val().replace(/\D/g, '');
if (otp.length !== 6) { alertBox('warning', 'Enter the 6-digit OTP.'); return; }
var button = $('#abhaVerifyOtpBtn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Verifying');
$.post('<?= base_url('abha/register/login/verify-otp') ?>', { txn_id:lookupResponse.txn_id, auth_method:lookupResponse.auth_method, otp:otp, '<?= 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();
renderProfile(response);
}, '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.'));
});
}
$(function () {
modal = new bootstrap.Modal(document.getElementById('abhaVerifyModal'));
$('#abhaVerifyLookupBtn').on('click', lookup);
$('#abhaVerifyIdentifier').on('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); lookup(); } });
$('#abhaVerifyBackLookupBtn').on('click', function () { showStep(1); });
$('#abhaVerifySendOtpBtn').on('click', requestOtp);
$('#abhaVerifyOtpBtn').on('click', verifyOtp);
$('#abhaVerifyOtp').on('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); verifyOtp(); } });
$('#abhaVerifyResendBtn').on('click', requestOtp);
$('#abhaVerifyCompareBtn').on('click', function () {
if (!verifiedProfile) return;
var profile = verifiedProfile;
$('#abhaVerifyModal').one('hidden.bs.modal', function () {
if (typeof onVerified === 'function') onVerified(profile);
});
modal.hide();
});
$('#abhaVerifyModal').on('hidden.bs.modal', stopTimer);
});
return {
open: function (identifier, initialLookup, callback) {
lookupResponse = initialLookup || null;
verifiedProfile = null;
onVerified = callback;
$('#abhaVerifyIdentifier').val(identifier || '');
$('#abhaVerifyPhoto,#abhaVerifyDownloadCard').addClass('d-none');
showStep(1);
modal.show();
if (initialLookup && initialLookup.ok == 1) {
var account = initialLookup.account || {};
$('#abhaVerifyFoundName').text(account.name || 'ABHA account found');
$('#abhaVerifyFoundIdentity').text(account.abha_address || account.abha_number || identifier || '');
renderAuthMethods(initialLookup);
showStep(2);
}
}
};
})();
</script>

View File

@ -0,0 +1,198 @@
# ABHA Identifier Login Bridge Contract
Version: 1.0
This contract supports the HMS `/billing/patient` modal flow:
1. Enter ABHA number or ABHA address.
2. Choose an authentication method returned by ABDM.
3. Send and verify OTP with a 60-second resend cooldown.
4. Review the verified ABHA profile and official ABHA card.
5. Compare with an existing HMS patient before linking or updating.
Every request requires:
- `Authorization: Bearer {hospital-token}`
- `Content-Type: application/json`
- `hfr_id` in the request body
The Bridge must never return an ABDM session or X-token to the browser-facing HMS response.
## 1. Search ABHA Login Account
Existing endpoint:
`POST /api/v3/abha/login/search`
Request:
```json
{
"type": "abha-address",
"value": "patient@sbx",
"hfr_id": "IN0510000828"
}
```
`type` must be `abha-address` or `abha-number`.
Required success response:
```json
{
"ok": 1,
"data": {
"txnId": "search-transaction-id",
"status": "ACTIVE",
"authMethods": ["MOBILE_OTP", "AADHAAR_OTP"],
"blockedAuthMethods": [],
"maskedMobile": "******1369",
"accounts": [
{
"name": "Ashish Soni",
"ABHANumber": "91201531584567",
"abhaAddress": "ashishtest6@sbx",
"status": "ACTIVE"
}
]
},
"request_id": "REQ-..."
}
```
The search transaction must remain valid for the next OTP request. Do not replace it with a new mobile-search transaction.
## 2. Request Account-Bound OTP
New endpoint:
`POST /api/v3/abha/login/request-otp`
Request:
```json
{
"txn_id": "search-transaction-id",
"auth_method": "MOBILE_OTP",
"abha_id": "91201531584567",
"abha_address": "ashishtest6@sbx",
"hfr_id": "IN0510000828"
}
```
Rules:
- `auth_method` must have been returned by the search response.
- Supported HMS methods are `MOBILE_OTP` and `AADHAAR_OTP`.
- `MOBILE_OTP` means the mobile registered with the ABHA account.
- `AADHAAR_OTP` means the mobile linked with Aadhaar.
- The operator must not enter a mobile number.
- Reject blocked or unavailable methods.
- Apply Bridge-side rate limiting in addition to the HMS timer.
Required success response:
```json
{
"ok": 1,
"data": {
"txnId": "otp-transaction-id",
"authMethod": "MOBILE_OTP",
"maskedMobile": "******1369",
"resendAfter": 60
},
"request_id": "REQ-..."
}
```
For rate limiting, return HTTP `429` with `retry_after` when appropriate.
## 3. Verify Account-Bound OTP
New endpoint:
`POST /api/v3/abha/login/verify-otp`
Request:
```json
{
"txn_id": "otp-transaction-id",
"auth_method": "MOBILE_OTP",
"otp": "123456",
"hfr_id": "IN0510000828"
}
```
Bridge responsibilities after successful ABDM OTP verification:
1. Use the server-side ABDM session/X-token to fetch the official profile.
2. Call the ABDM profile endpoint equivalent to `GET /profile/account`.
3. Call the official card endpoint equivalent to `GET /profile/account/abha-card`.
4. Normalize the profile and card into one response.
5. Persist only required audit/profile fields according to Bridge policy.
6. Do not return session tokens, access tokens, Aadhaar numbers, or unneeded raw ABDM payloads.
Required success response:
```json
{
"ok": 1,
"data": {
"profile": {
"ABHANumber": "91201531584567",
"preferredAddress": "ashishtest6@sbx",
"name": "Ashish Soni",
"gender": "M",
"dob": "2001-02-19",
"mobile": "9098221369",
"address": "Rathkhana School Road, Gwalior",
"districtName": "Gwalior",
"stateName": "Madhya Pradesh",
"pinCode": "474001",
"profilePhoto": "base64-jpeg-without-data-prefix"
},
"card_base64": "base64-png-or-pdf-without-data-prefix",
"card_content_type": "image/png"
},
"request_id": "REQ-..."
}
```
The response may also use `gateway_patient` for profile data because HMS already normalizes that shape.
## 4. Standard Errors
Use the Bridge standard shape:
```json
{
"ok": 0,
"error_code": "OTP_RATE_LIMITED",
"message": "Wait before requesting another OTP.",
"details": {
"retry_after": 42
},
"request_id": "REQ-..."
}
```
Recommended codes:
- `ABHA_NOT_FOUND` (`404`)
- `AUTH_METHOD_UNAVAILABLE` (`422`)
- `AUTH_METHOD_BLOCKED` (`423`)
- `OTP_INVALID` (`422`)
- `OTP_EXPIRED` (`410`)
- `OTP_RATE_LIMITED` (`429`)
- `PROFILE_FETCH_FAILED` (`502`)
- `CARD_FETCH_FAILED` (`502`) only when card failure should block; otherwise return profile with an empty card and a warning
## 5. Security and Audit
- Redact OTP, Aadhaar, tokens, and profile/card base64 from logs.
- Bind OTP transactions to the hospital, HFR ID, ABHA account, and selected auth method.
- Expire transactions according to ABDM rules.
- Enforce request throttling per hospital, ABHA identity, and transaction.
- Return a `request_id` for all failures.
- Keep the full ABDM session and card fetch server-side.