Add ABHA QR scan registration flow

This commit is contained in:
DevSoftTech Server 2026-08-13 19:51:14 +05:30
parent 3470244061
commit 6a6895ce35
4 changed files with 336 additions and 7 deletions

View File

@ -776,6 +776,7 @@ $routes->post('abha/create/address', 'Abha::address', ['filter' => 'permission:a
$routes->post('abha/create/address_suggestions', 'Abha::addressSuggestions', ['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/qr_scan', 'Abha::qrScan', ['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']);

View File

@ -2047,6 +2047,104 @@ class Abha extends BaseController
]);
}
// -------------------------------------------------------------------------
// ABHA QR scan — POST abha/register/qr_scan
// Accepts the JSON payload printed on an ABHA card / PHR app QR code and
// returns the demographics plus possible existing HMS patients. The QR is a
// demographic capture, not an OTP authentication, so it is never marked VERIFIED.
// -------------------------------------------------------------------------
public function qrScan()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$raw = trim((string) ($this->request->getPost('qr_data') ?? ''));
if ($raw === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Scan or paste the ABHA QR content first.']);
}
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
// Some cards/apps encode only the ABHA number or address.
$digits = preg_replace('/\D/', '', $raw);
if (strlen($digits) === 14) {
$decoded = ['hidn' => $digits];
} elseif (str_contains($raw, '@')) {
$decoded = ['hid' => $raw];
} else {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'This QR code is not a recognised ABHA card. Scan the QR printed on the ABHA card or shown in the ABHA/PHR app.',
]);
}
}
// ABHA QR keys vary by issuer ("state name", "stateName", "state_name").
$normalized = [];
foreach ($decoded as $key => $value) {
$flatKey = strtolower(preg_replace('/[^a-z0-9]/i', '', (string) $key));
if ($flatKey !== '' && is_scalar($value)) {
$normalized[$flatKey] = trim((string) $value);
}
}
$pick = static function (array $keys) use ($normalized): string {
foreach ($keys as $key) {
if (trim((string) ($normalized[$key] ?? '')) !== '') {
return trim((string) $normalized[$key]);
}
}
return '';
};
$abhaNumber = preg_replace('/\D/', '', $pick(['hidn', 'abhanumber', 'healthidnumber', 'abhano', 'abhaid']));
$abhaAddress = $pick(['hid', 'phr', 'phraddress', 'abhaaddress', 'healthid', 'preferredabhaaddress']);
$name = $pick(['name', 'fullname', 'patientname']);
$gender = $pick(['gender', 'sex']);
$dob = $pick(['dob', 'dateofbirth', 'birthdate']);
$mobile = preg_replace('/\D/', '', $pick(['mobile', 'mobilenumber', 'phone', 'contact']));
$address = $pick(['address', 'addressline']);
$district = $pick(['districtname', 'district', 'distname']);
$state = $pick(['statename', 'state']);
$zip = $pick(['pincode', 'pin', 'postalcode', 'zip']);
if ($abhaNumber === '' && $abhaAddress === '') {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'The QR code did not contain an ABHA number or ABHA address.',
]);
}
if ($abhaNumber === '' && str_contains($abhaAddress, '@')) {
$candidateDigits = preg_replace('/\D/', '', explode('@', $abhaAddress)[0]);
if (strlen($candidateDigits) === 14) {
$abhaNumber = $candidateDigits;
}
}
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
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,
'verification_type' => 'QR_SCAN',
'candidates' => $this->findMatchingCandidates($db, $fields, $name, $mobile, $gender, $dob, '', $abhaField, $abhaNumber),
]);
}
private function extractAbhaCardData(array $payload): string
{
$sources = [$payload, $payload['data'] ?? [], $payload['account'] ?? [], $payload['profile'] ?? [], $payload['gateway_patient'] ?? [], $payload['data']['gateway_patient'] ?? []];

View File

@ -43,6 +43,7 @@
<?= view('partials/abha_patient_match_modal') ?>
<?= view('partials/abha_verify_modal') ?>
<?= view('partials/abha_create_modal') ?>
<?= view('partials/abha_qr_modal') ?>
<section class="content">
<div class="row">
<div class="col-md-12">
@ -731,7 +732,16 @@
<!-- Panel: Method 2 - Scan QR -->
<div id="abhareg_panel_qr" class="abhareg-panel d-none">
<p class="text-muted small mb-2">Point camera at patient's ABHA QR code (from PHR app or printed card).</p>
<p class="text-muted small mb-2">Scan the ABHA QR from the patient's card or PHR app.</p>
<div class="d-flex gap-2 flex-wrap mb-3">
<button type="button" id="abhareg_qr_open_modal_btn" class="btn btn-primary btn-sm">
<i class="bi bi-upc-scan me-1"></i>Use QR Scanner
</button>
<button type="button" id="abhareg_qr_start_camera_btn" class="btn btn-outline-primary btn-sm">
<i class="bi bi-camera me-1"></i>Use Device Camera
</button>
</div>
<div id="abhareg_qr_camera_wrap" class="d-none">
<div class="mb-2" style="max-width:300px;">
<label for="abhareg_qr_camera_select" class="form-label small mb-1">Camera</label>
<div class="d-flex gap-2 align-items-start">
@ -742,6 +752,7 @@
</div>
</div>
<div id="abhareg_qr_reader" style="width:300px;max-width:100%;"></div>
</div>
<div id="abhareg_qr_result" class="mt-2"></div>
<div class="mt-2">
<button type="button" id="abhareg_qr_stop_btn" class="btn btn-outline-secondary btn-sm d-none">
@ -1425,11 +1436,23 @@
if (method === 'number') {
$('#abhareg_panel_number').addClass('d-none');
openAbhaAccountVerification('', null);
} else if (method === 'qr') { initQrScanner(); }
} else if (method === 'qr') { openAbhaQrScan(); }
else { stopQrScanner(); }
if (method === 'facility') { loadFacilityQr(false); }
};
function openAbhaQrScan() {
if (!window.AbhaQrModal) {
regAlert('abhareg_qr_result', 'danger', 'ABHA QR modal is unavailable. Reload the page and try again.');
return;
}
window.AbhaQrModal.open(function(profile) {
window.AbhaPatientMatchModal.open(profile, profile.candidates || [], function(finalResponse) {
showRegResult(finalResponse);
});
});
}
function openAbhaAccountVerification(identifier, lookupResponse) {
if (!window.AbhaVerifyModal) {
regAlert('abhareg_num_stepA_alert', 'danger', 'ABHA verification modal is unavailable. Reload the page and try again.');
@ -1652,11 +1675,15 @@
{ fps: 10, qrbox: { width: 250, height: 250 } },
function(decodedText) {
stopQrScanner();
var abha = decodedText.trim();
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>');
openAbhaAccountVerification(abha, null);
var scanned = decodedText.trim();
$('#abhareg_qr_result').html('<div class="alert alert-success py-2"><i class="bi bi-qr-code me-2"></i>QR scanned.</div>');
if (window.AbhaQrModal) {
openAbhaQrScan();
window.AbhaQrModal.submit(scanned);
return;
}
var numMatch = scanned.match(/\d{14}/);
openAbhaAccountVerification(numMatch ? numMatch[0] : scanned, null);
},
function() {}
).then(function() {
@ -1890,6 +1917,11 @@
});
$('#abhareg_qr_stop_btn').on('click', function() { stopQrScanner(); });
$('#abhareg_qr_open_modal_btn').on('click', function() { openAbhaQrScan(); });
$('#abhareg_qr_start_camera_btn').on('click', function() {
$('#abhareg_qr_camera_wrap').removeClass('d-none');
initQrScanner(qrSelectedCamera || '');
});
$('#abhareg_qr_camera_select').on('change', function() {
var cameraId = ($(this).val() || '').toString();
saveQrSelectedCamera(cameraId);

View File

@ -0,0 +1,198 @@
<?php
/**
* Partial: "Scan ABHA QR" modal.
*
* Accepts the JSON payload encoded in an ABHA card / PHR app QR code, either from
* a hardware (keyboard-wedge) scanner or pasted manually, and hands the parsed
* demographics to the caller so the patient-match modal can take over.
*
* A QR scan captures demographics only — it is not an OTP authentication.
*
* Include once per page: <?= view('partials/abha_qr_modal') ?>
* Then call: window.AbhaQrModal.open(function (profile) { ... });
*/
?>
<style>
.abha-qr-modal .modal-content { border:0; border-radius:12px; overflow:hidden; }
.abha-qr-modal .modal-header { border-bottom:1px solid #e5eaf1; padding:18px 22px; }
.abha-qr-modal .modal-body { padding:22px; }
.abha-qr-target { border:2px dashed #b9c6d8; border-radius:10px; background:#f8fafc; padding:18px; text-align:center; }
.abha-qr-target.listening { border-color:#356cf4; background:#eef3ff; }
.abha-qr-input { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; }
.abha-qr-list { margin:0; }
.abha-qr-list>div { display:grid; grid-template-columns:150px minmax(0,1fr); gap:14px; padding:9px 0; border-bottom:1px solid #edf0f4; }
.abha-qr-list dt { color:#6b7688; font-weight:500; }
.abha-qr-list dd { margin:0; text-align:right; font-weight:600; overflow-wrap:anywhere; }
@media (max-width:575.98px){ .abha-qr-list>div{grid-template-columns:110px minmax(0,1fr)} }
</style>
<div class="modal fade abha-qr-modal" id="abhaQrModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-qr-code-scan text-primary me-2"></i>Scan ABHA QR</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="abhaQrAlert"></div>
<section id="abhaQrStep1">
<div class="abha-qr-target listening" id="abhaQrTarget">
<i class="bi bi-upc-scan text-primary" style="font-size:2.4rem"></i>
<h6 class="mt-2 mb-1">Ready to scan</h6>
<div class="text-muted small">Scan the QR on the patient's ABHA card or PHR app. The scanner types into the box below automatically.</div>
</div>
<label class="form-label fw-semibold mt-3" for="abhaQrInput">Scanned QR content</label>
<textarea class="form-control abha-qr-input" id="abhaQrInput" rows="4" placeholder='Scan now, or paste the QR text (e.g. {"hidn":"91-0000-0000-0000", ...})'></textarea>
<div class="form-text">A hardware scanner submits automatically. Pasting? Click <strong>Read QR</strong>.</div>
<div class="d-flex justify-content-end gap-2 mt-3">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" id="abhaQrClearBtn">Clear</button>
<button type="button" class="btn btn-primary" id="abhaQrReadBtn"><i class="bi bi-search me-1"></i>Read QR</button>
</div>
</section>
<section id="abhaQrStep2" class="d-none">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge bg-primary-subtle text-primary"><i class="bi bi-qr-code me-1"></i>Scanned from ABHA QR</span>
<span class="badge bg-warning-subtle text-warning-emphasis">Not OTP verified</span>
</div>
<h4 class="mb-0" id="abhaQrName">-</h4>
<div class="text-muted mb-3" id="abhaQrAddress">-</div>
<dl class="abha-qr-list">
<div><dt>ABHA Number</dt><dd id="abhaQrNumber">-</dd></div>
<div><dt>ABHA Address</dt><dd id="abhaQrAddressValue">-</dd></div>
<div><dt>Date of Birth</dt><dd id="abhaQrDob">-</dd></div>
<div><dt>Gender</dt><dd id="abhaQrGender">-</dd></div>
<div><dt>Mobile</dt><dd id="abhaQrMobile">-</dd></div>
<div><dt>Address</dt><dd id="abhaQrFullAddress">-</dd></div>
</dl>
<div class="d-flex justify-content-between gap-2 mt-4">
<button type="button" class="btn btn-outline-secondary" id="abhaQrRescanBtn"><i class="bi bi-arrow-left me-1"></i>Scan Again</button>
<button type="button" class="btn btn-success" id="abhaQrContinueBtn"><i class="bi bi-people me-1"></i>Compare with HMS Patients</button>
</div>
</section>
</div>
</div>
</div>
</div>
<script>
window.AbhaQrModal = (function () {
'use strict';
var modal;
var onResolved = null;
var scannedProfile = null;
var autoSubmitTimer = null;
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) {
$('#abhaQrAlert').html(message ? '<div class="alert alert-' + type + ' py-2">' + message + '</div>' : '');
}
function genderText(value) {
var gender = String(value || '').toUpperCase();
if (gender === 'M' || gender === '1' || gender === 'MALE') return 'Male';
if (gender === 'F' || gender === '2' || gender === 'FEMALE') return 'Female';
if (gender === 'O' || gender === '3' || gender === 'OTHER') return 'Other';
return value || '-';
}
function formatAbha(value) { return String(value || '').replace(/(\d{2})(\d{4})(\d{4})(\d{4})/, '$1-$2-$3-$4'); }
function maskMobile(value) {
var digits = String(value || '').replace(/\D/g, '');
return digits.length === 10 ? digits.replace(/(\d{6})(\d{4})/, '******$2') : (value || '-');
}
function showStep(step) {
$('#abhaQrStep1,#abhaQrStep2').addClass('d-none');
$('#abhaQrStep' + step).removeClass('d-none');
alertBox('', '');
}
function readQr() {
window.clearTimeout(autoSubmitTimer);
var payload = $.trim($('#abhaQrInput').val());
if (payload === '') { alertBox('warning', 'Scan the ABHA QR or paste its content first.'); return; }
var button = $('#abhaQrReadBtn').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Reading');
$.post('<?= base_url('abha/register/qr_scan') ?>', { qr_data: payload, '<?= csrf_token() ?>': csrf() }, function (response) {
button.prop('disabled', false).html('<i class="bi bi-search me-1"></i>Read QR');
if (!response || response.ok != 1) { alertBox('danger', apiMessage(response, 'Unable to read this ABHA QR code.')); return; }
scannedProfile = response;
$('#abhaQrName').text(response.name || '-');
$('#abhaQrAddress').text(response.abha_address || formatAbha(response.abha_number) || '-');
$('#abhaQrNumber').text(formatAbha(response.abha_number) || '-');
$('#abhaQrAddressValue').text(response.abha_address || '-');
$('#abhaQrDob').text(response.dob || '-');
$('#abhaQrGender').text(genderText(response.gender));
$('#abhaQrMobile').text(maskMobile(response.mobile));
$('#abhaQrFullAddress').text([response.address, response.district, response.state, response.zip].filter(Boolean).join(', ') || '-');
showStep(2);
}, 'json').fail(function (xhr) {
button.prop('disabled', false).html('<i class="bi bi-search me-1"></i>Read QR');
alertBox('danger', apiMessage(xhr.responseJSON, 'Unable to read this ABHA QR code.'));
});
}
function resetToScan() {
scannedProfile = null;
$('#abhaQrInput').val('');
showStep(1);
$('#abhaQrInput').trigger('focus');
}
$(function () {
modal = new bootstrap.Modal(document.getElementById('abhaQrModal'));
// Keyboard-wedge scanners finish with Enter; fall back to a short idle timer.
$('#abhaQrInput').on('keydown', function (event) {
if (event.key === 'Enter') { event.preventDefault(); readQr(); }
}).on('input', function () {
window.clearTimeout(autoSubmitTimer);
var value = $.trim($(this).val());
if (value.length < 20) { return; }
autoSubmitTimer = window.setTimeout(function () {
if ($('#abhaQrStep1').hasClass('d-none')) { return; }
readQr();
}, 350);
});
$('#abhaQrReadBtn').on('click', readQr);
$('#abhaQrClearBtn').on('click', resetToScan);
$('#abhaQrRescanBtn').on('click', resetToScan);
$('#abhaQrContinueBtn').on('click', function () {
if (!scannedProfile) return;
var profile = scannedProfile;
$('#abhaQrModal').one('hidden.bs.modal', function () {
if (typeof onResolved === 'function') onResolved(profile);
});
modal.hide();
});
$('#abhaQrModal').on('shown.bs.modal', function () { $('#abhaQrInput').trigger('focus'); });
$('#abhaQrModal').on('hidden.bs.modal', function () { window.clearTimeout(autoSubmitTimer); });
});
return {
open: function (callback) {
onResolved = callback;
resetToScan();
alertBox('', '');
modal.show();
},
submit: function (decodedText) {
$('#abhaQrInput').val(decodedText || '');
readQr();
}
};
})();
</script>