Add ABHA QR scan registration flow
This commit is contained in:
parent
3470244061
commit
6a6895ce35
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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'] ?? []];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
198
app/Views/partials/abha_qr_modal.php
Normal file
198
app/Views/partials/abha_qr_modal.php
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user