Complete ABHA account verification flow
This commit is contained in:
parent
35fcb07434
commit
a5447cb83a
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
287
app/Views/partials/abha_verify_modal.php
Normal file
287
app/Views/partials/abha_verify_modal.php
Normal 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>
|
||||
198
docs/abdm/ABHA_IDENTIFIER_LOGIN_BRIDGE_CONTRACT.md
Normal file
198
docs/abdm/ABHA_IDENTIFIER_LOGIN_BRIDGE_CONTRACT.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user