- Extract and contrast both requested and patient-granted date ranges and eraseAt timestamps from ABDM consent artefacts - Fix premature session expiration when patient grants longer consent validity than initially requested by HIU - Update UI labels from Valid From / Valid To to Request From / Request To and Consent Valid Upto - Show original requested dates struck-through and granted dates highlighted with Updated badges in consent detail modals - Add visual indicators for patient-modified dates in consent requests tables
7466 lines
258 KiB
PHP
7466 lines
258 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Controllers\BaseController;
|
|
use App\Libraries\Abdm\AbdmConnectorFactory;
|
|
use App\Libraries\AbdmWorkTaskService;
|
|
use App\Libraries\BridgeSyncService;
|
|
use App\Models\BloodGroupModel;
|
|
use App\Models\PatientModel;
|
|
|
|
class Patient extends BaseController
|
|
{
|
|
// A consent request left in REQUESTED/PENDING for longer than this (no GRANTED/DENIED
|
|
// callback from the ABDM bridge) is treated as abandoned/stale so "Fetch ABDM Records"
|
|
// starts a fresh consent request instead of resuming a request that will never resolve.
|
|
private const ABDM_PENDING_STALE_SECONDS = 3600;
|
|
|
|
public function index()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return view('welcome_message', [
|
|
'initial_route' => base_url('billing/patient'),
|
|
'initial_title' => 'Patient Management',
|
|
]);
|
|
}
|
|
|
|
$user = auth()->user();
|
|
|
|
$bloodGroupModel = new BloodGroupModel();
|
|
$data['blood_group'] = $bloodGroupModel
|
|
->orderBy('id', 'ASC')
|
|
->findAll();
|
|
|
|
$data['user'] = $user;
|
|
|
|
$data['india_states'] = [];
|
|
if ($this->db->tableExists('india_state')) {
|
|
$data['india_states'] = $this->db->table('india_state')
|
|
->select('id, state_name')
|
|
->orderBy('state_name', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
}
|
|
|
|
$data['refer_master'] = $this->getActiveReferMasters();
|
|
|
|
return view('billing/Patient_V', $data);
|
|
}
|
|
|
|
public function search_opd()
|
|
{
|
|
$sql = "SELECT p.*,
|
|
Date_Format(o.apointment_date,'%d-%m-%Y') AS opd_Visit,o.doc_name,o.doc_spec,o.opd_code,i.short_name
|
|
FROM (patient_master p join opd_master o on p.id =o.p_id)
|
|
Left join hc_insurance i on i.id = o.insurance_id
|
|
order BY o.opd_id DESC LIMIT 100 ";
|
|
|
|
$query = $this->db->query($sql);
|
|
$data['data'] = $query->getResult();
|
|
|
|
return view('billing/Patient_Search_opd', $data);
|
|
}
|
|
|
|
public function create()
|
|
{
|
|
$isAjax = $this->request->isAJAX();
|
|
$abhaIdInput = trim((string) $this->request->getPost('input_abha_id'));
|
|
$abhaAddressInput = trim((string) $this->request->getPost('input_abha_address'));
|
|
[$abhaId, $abhaAddress, $abhaError] = $this->normalizeAbhaInputs($abhaIdInput, $abhaAddressInput);
|
|
|
|
$chk_age = $this->request->getPost('chk_age');
|
|
$age_month = (string) $this->request->getPost('input_age_month');
|
|
$age_year = (string) $this->request->getPost('input_age_year');
|
|
|
|
$rules = [
|
|
'input_name' => 'required|min_length[1]|max_length[100]',
|
|
'input_relative_name' => 'required|min_length[1]|max_length[100]',
|
|
'input_mphone1' => 'required|min_length[10]|max_length[10]',
|
|
];
|
|
|
|
if ($chk_age === 'on') {
|
|
$estimate_dob = 1;
|
|
if ($age_year === '' && $age_month === '') {
|
|
$rules['input_age_year'] = 'required|min_length[1]|max_length[4]';
|
|
}
|
|
} else {
|
|
$estimate_dob = 0;
|
|
$rules['datepicker_dob'] = 'required|min_length[1]|max_length[10]';
|
|
}
|
|
|
|
$validation = service('validation');
|
|
$validation->setRules($rules);
|
|
if (!$validation->withRequest($this->request)->run()) {
|
|
$errorText = implode("\n", $validation->getErrors());
|
|
if ($isAjax) {
|
|
return $this->response->setJSON([
|
|
'insertid' => 0,
|
|
'error_text' => $errorText,
|
|
]);
|
|
}
|
|
|
|
return redirect()->to(base_url('billing/patient'))
|
|
->withInput()
|
|
->with('error', $errorText);
|
|
}
|
|
|
|
if ($abhaError !== null) {
|
|
if ($isAjax) {
|
|
return $this->response->setJSON([
|
|
'insertid' => 0,
|
|
'error_text' => $abhaError,
|
|
]);
|
|
}
|
|
|
|
return redirect()->to(base_url('billing/patient'))
|
|
->withInput()
|
|
->with('error', $abhaError);
|
|
}
|
|
|
|
$referByName = trim((string) $this->request->getPost('refer_by_name'));
|
|
$referByError = $this->validatePatientReferByInput($referByName);
|
|
if ($referByError !== null) {
|
|
if ($isAjax) {
|
|
return $this->response->setJSON([
|
|
'insertid' => 0,
|
|
'error_text' => $referByError,
|
|
]);
|
|
}
|
|
|
|
return redirect()->to(base_url('billing/patient'))
|
|
->withInput()
|
|
->with('error', $referByError);
|
|
}
|
|
|
|
// Enforce unique ABHA ID across patients
|
|
if ($abhaId !== '') {
|
|
$conflictAbha = $this->findPatientByAbha($abhaId);
|
|
if ($conflictAbha !== null) {
|
|
$msg = 'This ABHA ID is already linked to patient ' . ($conflictAbha['p_code'] ?? '') . ' (' . ($conflictAbha['p_fname'] ?? '') . '). ABHA ID must be unique to one person.';
|
|
if ($isAjax) {
|
|
return $this->response->setJSON([
|
|
'insertid' => 0,
|
|
'error_text' => $msg,
|
|
]);
|
|
}
|
|
|
|
return redirect()->to(base_url('billing/patient'))
|
|
->withInput()
|
|
->with('error', $msg);
|
|
}
|
|
}
|
|
|
|
// Enforce unique Aadhaar Number across patients
|
|
$rawAadhaar = trim((string) $this->request->getPost('input_udai'));
|
|
if ($rawAadhaar !== '') {
|
|
$vault = new \App\Libraries\AadhaarVaultService();
|
|
if (! $vault->isMasked($rawAadhaar)) {
|
|
$digits = $vault->normalize($rawAadhaar);
|
|
if ($digits !== '') {
|
|
$conflictAadhaar = $this->findPatientByAadhaar($digits);
|
|
if ($conflictAadhaar !== null) {
|
|
$msg = 'This Aadhaar Number is already linked to patient ' . ($conflictAadhaar['p_code'] ?? '') . ' (' . ($conflictAadhaar['p_fname'] ?? '') . '). Aadhaar Number must be unique to one person.';
|
|
if ($isAjax) {
|
|
return $this->response->setJSON([
|
|
'insertid' => 0,
|
|
'error_text' => $msg,
|
|
]);
|
|
}
|
|
|
|
return redirect()->to(base_url('billing/patient'))
|
|
->withInput()
|
|
->with('error', $msg);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$bloodGroup = trim((string) $this->request->getPost('input_blood_group'));
|
|
if ($bloodGroup === '') {
|
|
$bloodGroup = 'Not Define';
|
|
}
|
|
|
|
$data = [
|
|
'mphone1' => $this->request->getPost('input_mphone1'),
|
|
'p_fname' => strtoupper((string) $this->request->getPost('input_name')),
|
|
'gender' => $this->request->getPost('optionsRadios_gender'),
|
|
'zip' => $this->request->getPost('input_zip'),
|
|
'add1' => strtoupper((string) $this->request->getPost('input_address')),
|
|
'city' => strtoupper((string) $this->request->getPost('input_city')),
|
|
'district' => strtoupper((string) $this->request->getPost('input_district')),
|
|
'state' => strtoupper((string) $this->request->getPost('input_state')),
|
|
'title' => $this->request->getPost('cbo_title'),
|
|
'p_relative' => $this->request->getPost('cbo_relation'),
|
|
'p_rname' => strtoupper((string) $this->request->getPost('input_relative_name')),
|
|
'blood_group' => strtoupper($bloodGroup),
|
|
'estimate_dob' => $estimate_dob,
|
|
];
|
|
$data += (new \App\Libraries\AadhaarVaultService())->buildColumns((string) $this->request->getPost('input_udai'));
|
|
$this->applyPatientReferbyField($data, $referByName);
|
|
$this->applyPatientAbhaFieldValues($data, $abhaId, $abhaAddress);
|
|
|
|
if ($chk_age === 'on') {
|
|
$data['age'] = $age_year;
|
|
$data['age_in_month'] = $age_month;
|
|
} else {
|
|
$data['dob'] = $this->parseDate($this->request->getPost('datepicker_dob'));
|
|
}
|
|
|
|
$patientModel = new PatientModel();
|
|
$insertId = $patientModel->insertPatient($data);
|
|
if ($insertId <= 0) {
|
|
if ($isAjax) {
|
|
return $this->response->setJSON([
|
|
'insertid' => 0,
|
|
'error_text' => 'Unable to save patient.',
|
|
]);
|
|
}
|
|
|
|
return redirect()->to(base_url('billing/patient'))
|
|
->withInput()
|
|
->with('error', 'Unable to save patient.');
|
|
}
|
|
|
|
$this->saveNamesToNameList([
|
|
(string) $this->request->getPost('input_name'),
|
|
(string) $this->request->getPost('input_relative_name'),
|
|
]);
|
|
|
|
$this->enqueuePatientAbhaSync($insertId, $data, 'patient.created');
|
|
$this->enqueuePatientSmsNotify($insertId, $data, 'patient.created');
|
|
$this->createPatientAbdmWorkTask($insertId, $data, $abhaId);
|
|
|
|
// Check Multiple UHID
|
|
$relativeName = trim(strtoupper((string) $this->request->getPost('input_relative_name')));
|
|
$patientName = trim(strtoupper((string) $this->request->getPost('input_name')));
|
|
|
|
$builder = $this->db->table('patient_master');
|
|
$builder->select("id, CONCAT(p_code,'/',p_fname,'/',IF(gender=1,'M','F'),'/',p_relative,' ',p_rname) as Sresult");
|
|
$builder->groupStart()
|
|
->where('p_rname', $relativeName)
|
|
->where('p_fname', $patientName)
|
|
->groupEnd();
|
|
$builder->where('id <>', $insertId);
|
|
$builder->orderBy('id', 'DESC');
|
|
$search_result = $builder->get()->getResult();
|
|
|
|
$logText = '';
|
|
foreach ($search_result as $row) {
|
|
$logText .= $row->Sresult . PHP_EOL;
|
|
}
|
|
|
|
if (count($search_result) > 0) {
|
|
$user = auth()->user();
|
|
$userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User';
|
|
$userId = $user->id ?? '';
|
|
$userName = $userLabel . '[Date:' . date('d-m-Y H:i:s') . ']-' . $userId;
|
|
|
|
$dupData = [
|
|
'new_uhid' => $insertId,
|
|
'name_of_person' => $patientName,
|
|
'new_patient_code' => '',
|
|
'date_of_registration' => date('Y-m-d H:i:s'),
|
|
'update_by' => $userName,
|
|
'remark_duplicate' => $logText,
|
|
];
|
|
|
|
$patientModel->insertDuplicateLog($dupData);
|
|
}
|
|
|
|
if ($isAjax) {
|
|
return $this->response->setJSON(['insertid' => $insertId]);
|
|
}
|
|
|
|
return redirect()->to(base_url('billing/patient/person_record/' . $insertId));
|
|
}
|
|
|
|
public function search_adv()
|
|
{
|
|
$inputMphone1 = trim((string) $this->request->getPost('input_mphone1'));
|
|
$inputAadhar = trim((string) $this->request->getPost('input_udai'));
|
|
$inputAbhaId = trim((string) $this->request->getPost('input_abha_id'));
|
|
$inputAbhaAddress = trim((string) $this->request->getPost('input_abha_address'));
|
|
$inputRelativeName = trim((string) $this->request->getPost('input_relative_name'));
|
|
$inputName = trim((string) $this->request->getPost('input_name'));
|
|
$abhaField = $this->resolvePatientAbhaIdField();
|
|
$hasAbhaAddress = $this->db->fieldExists('abha_address', 'patient_master');
|
|
|
|
$builder = $this->db->table('patient_master');
|
|
$builder->select("id,p_fname,p_relative,p_rname,gender,add1,city,district,state,zip,mphone1,udai,udai_last4,last_visit,dob,age,age_in_month,estimate_dob");
|
|
if ($this->db->fieldExists('referby', 'patient_master')) {
|
|
$builder->select('referby');
|
|
}
|
|
if ($abhaField !== null) {
|
|
$builder->select($abhaField . ' AS abha_id');
|
|
}
|
|
if ($hasAbhaAddress) {
|
|
$builder->select('abha_address');
|
|
}
|
|
|
|
$inputAbhaTerm = $inputAbhaId !== '' ? $inputAbhaId : $inputAbhaAddress;
|
|
|
|
$hasCondition = false;
|
|
if ($inputMphone1 !== '' || $inputAadhar !== '' || $inputAbhaTerm !== '' || ($inputRelativeName !== '' && $inputName !== '')) {
|
|
$builder->groupStart();
|
|
|
|
if ($inputMphone1 !== '') {
|
|
$builder->where('mphone1', $inputMphone1);
|
|
$hasCondition = true;
|
|
}
|
|
|
|
if ($inputAadhar !== '') {
|
|
$cleanAadhaar = preg_replace('/\D/', '', $inputAadhar) ?? '';
|
|
if (strlen($cleanAadhaar) === 12) {
|
|
$vault = new \App\Libraries\AadhaarVaultService();
|
|
$aadhaarHash = $vault->hash($cleanAadhaar);
|
|
$formattedAadhaar = substr($cleanAadhaar, 0, 4) . ' ' . substr($cleanAadhaar, 4, 4) . ' ' . substr($cleanAadhaar, 8, 4);
|
|
$pmCols = $this->db->getFieldNames('patient_master') ?? [];
|
|
|
|
$builder->orGroupStart();
|
|
$hasAadhaarOr = false;
|
|
if (in_array('udai_hash', $pmCols, true) && $aadhaarHash !== '') {
|
|
$builder->where('udai_hash', $aadhaarHash);
|
|
$hasAadhaarOr = true;
|
|
}
|
|
if (in_array('udai', $pmCols, true)) {
|
|
if ($hasAadhaarOr) {
|
|
$builder->orWhereIn('udai', [$cleanAadhaar, $formattedAadhaar]);
|
|
} else {
|
|
$builder->whereIn('udai', [$cleanAadhaar, $formattedAadhaar]);
|
|
}
|
|
}
|
|
$builder->groupEnd();
|
|
$hasCondition = true;
|
|
}
|
|
}
|
|
|
|
if ($inputAbhaTerm !== '') {
|
|
$cleanAbha = preg_replace('/\D/', '', $inputAbhaTerm) ?? '';
|
|
$builder->orGroupStart();
|
|
if (strlen($cleanAbha) === 14) {
|
|
$formattedAbha = substr($cleanAbha, 0, 2) . '-' . substr($cleanAbha, 2, 4) . '-' . substr($cleanAbha, 6, 4) . '-' . substr($cleanAbha, 10, 4);
|
|
if ($abhaField !== null) {
|
|
$builder->orWhereIn($abhaField, [$cleanAbha, $formattedAbha]);
|
|
}
|
|
if ($hasAbhaAddress) {
|
|
$builder->orWhereIn('abha_address', [$cleanAbha, $formattedAbha])
|
|
->orLike('abha_address', $cleanAbha . '@');
|
|
}
|
|
} else {
|
|
if ($abhaField !== null) {
|
|
$builder->orWhere($abhaField, $inputAbhaTerm)
|
|
->orLike($abhaField, $inputAbhaTerm);
|
|
}
|
|
if ($hasAbhaAddress) {
|
|
$builder->orWhere('abha_address', $inputAbhaTerm)
|
|
->orLike('abha_address', $inputAbhaTerm);
|
|
}
|
|
}
|
|
$builder->groupEnd();
|
|
$hasCondition = true;
|
|
}
|
|
|
|
if ($inputRelativeName !== '' && $inputName !== '') {
|
|
$builder->orGroupStart()
|
|
->where('p_rname', trim(strtoupper($inputRelativeName)))
|
|
->where('p_fname', trim(strtoupper($inputName)))
|
|
->groupEnd();
|
|
$hasCondition = true;
|
|
}
|
|
|
|
$builder->groupEnd();
|
|
}
|
|
|
|
$searchResult = [];
|
|
if ($hasCondition) {
|
|
$searchResult = $builder->orderBy('id', 'DESC')->limit(25)->get()->getResult();
|
|
}
|
|
|
|
return view('billing/search_adv_data', [
|
|
'search_result' => $searchResult,
|
|
'filters' => [
|
|
'input_mphone1' => $inputMphone1,
|
|
'input_udai' => strtoupper($inputAadhar),
|
|
'input_abha_id' => $inputAbhaTerm,
|
|
'input_relative_name' => strtoupper($inputRelativeName),
|
|
'input_name' => strtoupper($inputName),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function abha_fetch_profile()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$abhaId = trim((string) $this->request->getPost('abha_id'));
|
|
if (! $this->isValidAbhaId($abhaId)) {
|
|
return $this->response->setJSON(['ok' => 0, 'error_text' => 'ABHA ID must be a 14-digit number']);
|
|
}
|
|
|
|
// Check if this ABHA is already registered to a patient in HMS
|
|
$conflict = $this->findPatientByAbha($abhaId);
|
|
if ($conflict !== null) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'already_registered' => true,
|
|
'conflict_patient' => [
|
|
'id' => (int) $conflict['id'],
|
|
'p_code' => (string) ($conflict['p_code'] ?? ''),
|
|
'p_fname' => (string) ($conflict['p_fname'] ?? ''),
|
|
'profile_url' => base_url('billing/patient/person_record/' . (int) $conflict['id']),
|
|
],
|
|
'message' => 'This ABHA ID is already registered to patient ' . ($conflict['p_code'] ?? '') . ' (' . ($conflict['p_fname'] ?? '') . '). ABHA ID must be unique to one person.',
|
|
]);
|
|
}
|
|
|
|
$profile = [];
|
|
$queueId = null;
|
|
try {
|
|
$bridge = new BridgeSyncService();
|
|
$queueId = $bridge->enqueue('abdm.abha.profile.fetch.requested', [
|
|
'abha_id' => $abhaId,
|
|
'requested_at' => date('Y-m-d H:i:s'),
|
|
], 'abha', $abhaId);
|
|
} catch (\Throwable $e) {
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'already_registered' => false,
|
|
'queue_id' => $queueId,
|
|
'profile' => $profile,
|
|
'source' => 'abdm_queue',
|
|
]);
|
|
}
|
|
|
|
public function check_abha_unique()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$abha = trim((string) ($this->request->getPost('input_abha_id') ?? $this->request->getPost('abha_id') ?? ''));
|
|
$excludeId = (int) ($this->request->getPost('patient_id') ?? 0);
|
|
|
|
if ($abha === '') {
|
|
return $this->response->setJSON(['ok' => 1, 'is_unique' => true, 'exists' => false]);
|
|
}
|
|
|
|
$conflict = $this->findPatientByAbha($abha, $excludeId > 0 ? $excludeId : null);
|
|
if ($conflict !== null) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'is_unique' => false,
|
|
'exists' => true,
|
|
'conflict_patient' => [
|
|
'id' => (int) $conflict['id'],
|
|
'p_code' => (string) ($conflict['p_code'] ?? ''),
|
|
'p_fname' => (string) ($conflict['p_fname'] ?? ''),
|
|
'profile_url' => base_url('billing/patient/person_record/' . (int) $conflict['id']),
|
|
],
|
|
'message' => 'ABHA ID is already registered to patient ' . ($conflict['p_code'] ?? '') . ' (' . ($conflict['p_fname'] ?? '') . '). ABHA ID must be unique to one person.',
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'is_unique' => true,
|
|
'exists' => false,
|
|
'message' => 'ABHA ID is available.',
|
|
]);
|
|
}
|
|
|
|
public function check_aadhaar_unique()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$aadhaar = trim((string) ($this->request->getPost('input_udai') ?? $this->request->getPost('udai') ?? ''));
|
|
$excludeId = (int) ($this->request->getPost('patient_id') ?? 0);
|
|
|
|
if ($aadhaar === '') {
|
|
return $this->response->setJSON(['ok' => 1, 'is_unique' => true, 'exists' => false]);
|
|
}
|
|
|
|
$vault = new \App\Libraries\AadhaarVaultService();
|
|
if ($vault->isMasked($aadhaar)) {
|
|
return $this->response->setJSON(['ok' => 1, 'is_unique' => true, 'exists' => false, 'masked' => true]);
|
|
}
|
|
|
|
$digits = $vault->normalize($aadhaar);
|
|
if ($digits === '') {
|
|
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Aadhaar must be a 12-digit number']);
|
|
}
|
|
|
|
$conflict = $this->findPatientByAadhaar($digits, $excludeId > 0 ? $excludeId : null);
|
|
if ($conflict !== null) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'is_unique' => false,
|
|
'exists' => true,
|
|
'conflict_patient' => [
|
|
'id' => (int) $conflict['id'],
|
|
'p_code' => (string) ($conflict['p_code'] ?? ''),
|
|
'p_fname' => (string) ($conflict['p_fname'] ?? ''),
|
|
'profile_url' => base_url('billing/patient/person_record/' . (int) $conflict['id']),
|
|
],
|
|
'message' => 'Aadhaar Number is already registered to patient ' . ($conflict['p_code'] ?? '') . ' (' . ($conflict['p_fname'] ?? '') . '). Aadhaar Number must be unique to one person.',
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'is_unique' => true,
|
|
'exists' => false,
|
|
'message' => 'Aadhaar Number is available.',
|
|
]);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// ABHA M1 OTP Flows — accessible to billing staff (no abdm.* permission needed)
|
|
// -------------------------------------------------------------------------
|
|
|
|
public function abhaAadhaarGenerateOtp()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$isJson = str_contains($this->request->getHeaderLine('Content-Type'), 'application/json');
|
|
$body = $isJson ? ($this->request->getJSON(true) ?? []) : [];
|
|
$loginId = trim((string) ($body['loginId'] ?? $body['aadhaar'] ?? $this->request->getPost('loginId') ?? $this->request->getPost('aadhaar') ?? ''));
|
|
|
|
if ($loginId === '' || ! preg_match('/^\d{12}$/', $loginId)) {
|
|
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Valid 12-digit Aadhaar number is required']);
|
|
}
|
|
|
|
try {
|
|
$result = AbdmConnectorFactory::make()->abhaAadhaarGenerateOtp(['aadhaar' => $loginId]);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
|
|
}
|
|
|
|
return $this->response->setJSON($result);
|
|
}
|
|
|
|
public function abhaAadhaarVerifyOtp()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$isJson = str_contains($this->request->getHeaderLine('Content-Type'), 'application/json');
|
|
$body = $isJson ? ($this->request->getJSON(true) ?? []) : [];
|
|
$txnId = trim((string) ($body['txnId'] ?? $body['txn_id'] ?? $this->request->getPost('txnId') ?? $this->request->getPost('txn_id') ?? ''));
|
|
$otp = trim((string) ($body['otp'] ?? $this->request->getPost('otp') ?? ''));
|
|
$mobile = trim((string) ($body['mobile'] ?? $this->request->getPost('mobile') ?? ''));
|
|
$requestedPid = (int) ($body['p_id'] ?? $this->request->getPost('p_id') ?? 0);
|
|
|
|
if ($txnId === '' || $otp === '') {
|
|
return $this->response->setJSON(['ok' => 0, 'error_text' => 'txnId and otp are required']);
|
|
}
|
|
|
|
try {
|
|
$result = AbdmConnectorFactory::make()->abhaAadhaarVerifyOtp(['txnId' => $txnId, 'otp' => $otp, 'mobile' => $mobile]);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
|
|
}
|
|
|
|
if (! empty($result['ok']) && (int) $result['ok'] === 1) {
|
|
$result = $this->enrichAbhaVerifyResult($result, $requestedPid);
|
|
}
|
|
|
|
return $this->response->setJSON($this->sanitizeAbhaVerifyApiResponse($result));
|
|
}
|
|
|
|
public function abhaMobileGenerateOtp()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$isJson = str_contains($this->request->getHeaderLine('Content-Type'), 'application/json');
|
|
$body = $isJson ? ($this->request->getJSON(true) ?? []) : [];
|
|
$loginId = trim((string) ($body['loginId'] ?? $body['mobile'] ?? $this->request->getPost('loginId') ?? $this->request->getPost('mobile') ?? ''));
|
|
|
|
if ($loginId === '' || ! preg_match('/^\d{10}$/', $loginId)) {
|
|
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Valid 10-digit mobile number is required']);
|
|
}
|
|
|
|
try {
|
|
$result = AbdmConnectorFactory::make()->abhaMobileGenerateOtp(['mobile' => $loginId]);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
|
|
}
|
|
|
|
return $this->response->setJSON($result);
|
|
}
|
|
|
|
public function abhaMobileVerifyOtp()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$isJson = str_contains($this->request->getHeaderLine('Content-Type'), 'application/json');
|
|
$body = $isJson ? ($this->request->getJSON(true) ?? []) : [];
|
|
$txnId = trim((string) ($body['txnId'] ?? $body['txn_id'] ?? $this->request->getPost('txnId') ?? $this->request->getPost('txn_id') ?? ''));
|
|
$otp = trim((string) ($body['otp'] ?? $this->request->getPost('otp') ?? ''));
|
|
$requestedPid = (int) ($body['p_id'] ?? $this->request->getPost('p_id') ?? 0);
|
|
|
|
if ($txnId === '' || $otp === '') {
|
|
return $this->response->setJSON(['ok' => 0, 'error_text' => 'txnId and otp are required']);
|
|
}
|
|
|
|
try {
|
|
$result = AbdmConnectorFactory::make()->abhaMobileVerifyOtp(['txnId' => $txnId, 'otp' => $otp]);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
|
|
}
|
|
|
|
if (! empty($result['ok']) && (int) $result['ok'] === 1) {
|
|
$result = $this->enrichAbhaVerifyResult($result, $requestedPid);
|
|
}
|
|
|
|
return $this->response->setJSON($this->sanitizeAbhaVerifyApiResponse($result));
|
|
}
|
|
|
|
|
|
public function search()
|
|
{
|
|
$sdata = (string) $this->request->getPost('txtsearch');
|
|
$sdata = preg_replace('/[^A-Za-z0-9 _.@\/-]/', '', trim($sdata ?? ''));
|
|
|
|
$advSearchBy = trim((string) $this->request->getPost('adv_search_by'));
|
|
$advSearchValue = preg_replace('/[^A-Za-z0-9 _.@\/-]/', '', trim((string) $this->request->getPost('adv_search_value')));
|
|
$advAgeMode = trim((string) $this->request->getPost('adv_age_mode'));
|
|
$advAgeValue = preg_replace('/[^0-9]/', '', trim((string) $this->request->getPost('adv_age_value')));
|
|
$advAgeFrom = preg_replace('/[^0-9]/', '', trim((string) $this->request->getPost('adv_age_from')));
|
|
$advAgeTo = preg_replace('/[^0-9]/', '', trim((string) $this->request->getPost('adv_age_to')));
|
|
$advAgeTolerance = preg_replace('/[^0-9]/', '', trim((string) $this->request->getPost('adv_age_tolerance')));
|
|
$advAgeToleranceInt = $advAgeTolerance === '' ? 2 : (int) $advAgeTolerance;
|
|
if ($advAgeToleranceInt < 0) {
|
|
$advAgeToleranceInt = 0;
|
|
}
|
|
if ($advAgeToleranceInt > 20) {
|
|
$advAgeToleranceInt = 20;
|
|
}
|
|
|
|
$data['search_query'] = $sdata;
|
|
$data['advanced_filters'] = [
|
|
'adv_search_by' => $advSearchBy,
|
|
'adv_search_value' => $advSearchValue,
|
|
'adv_age_mode' => $advAgeMode,
|
|
'adv_age_value' => $advAgeValue,
|
|
'adv_age_from' => $advAgeFrom,
|
|
'adv_age_to' => $advAgeTo,
|
|
'adv_age_tolerance' => (string) $advAgeToleranceInt,
|
|
];
|
|
return view('billing/Patient_Search_V', $data);
|
|
}
|
|
|
|
protected function buildOldUhidSearchClause(string $rowData, ?string $oldUhidField): string
|
|
{
|
|
if ($oldUhidField === null || $oldUhidField === '') {
|
|
return '';
|
|
}
|
|
|
|
$escapedValue = $this->db->escape($rowData);
|
|
$escapedLikeValue = $this->db->escape('%' . $rowData . '%');
|
|
$escapedSuffixLikeValue = $this->db->escape('%' . $rowData);
|
|
$normalizedTerm = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $rowData));
|
|
|
|
$clauses = [
|
|
"p.{$oldUhidField} = " . $escapedValue,
|
|
'p.' . $oldUhidField . ' LIKE ' . $escapedLikeValue,
|
|
'p.' . $oldUhidField . ' LIKE ' . $escapedSuffixLikeValue,
|
|
];
|
|
|
|
if ($normalizedTerm !== '') {
|
|
$escapedNormalizedTerm = $this->db->escape($normalizedTerm);
|
|
$normalizedField = "UPPER(REPLACE(REPLACE(REPLACE(TRIM(COALESCE(p.{$oldUhidField}, '')), '/', ''), '-', ''), ' ', ''))";
|
|
$clauses[] = $normalizedField . ' = ' . $escapedNormalizedTerm;
|
|
$clauses[] = $normalizedField . ' LIKE ' . $this->db->escape('%' . $normalizedTerm . '%');
|
|
$clauses[] = $normalizedField . ' LIKE ' . $this->db->escape('%' . $normalizedTerm);
|
|
}
|
|
|
|
return "TRIM(COALESCE(p.{$oldUhidField}, '')) != '' AND (" . implode(' OR ', $clauses) . ')';
|
|
}
|
|
|
|
protected function formatPatientCodeCell(int $patientId, string $patientCode, string $oldUhid = '', string $abha = ''): string
|
|
{
|
|
$link = '<a href="javascript:load_form(\'' . base_url('billing/patient/person_record/' . $patientId) . '\');">' . esc($patientCode) . '</a>';
|
|
$oldUhid = trim($oldUhid);
|
|
$abha = trim($abha);
|
|
|
|
if ($oldUhid !== '') {
|
|
$link .= '<br><small><i>' . esc($oldUhid) . '</i></small>';
|
|
}
|
|
if ($abha !== '') {
|
|
$link .= '<br><small class="text-primary"><i class="fa fa-id-card-o me-1"></i>' . esc($abha) . '</small>';
|
|
}
|
|
|
|
return $link;
|
|
}
|
|
|
|
protected function buildPatientSearchCondition(
|
|
string $rowData,
|
|
?string $abhaField = null,
|
|
?string $oldUhidField = null,
|
|
array $additionalAbhaFields = [],
|
|
?string $abhaAddressField = null
|
|
): string {
|
|
$escapedValue = $this->db->escape($rowData);
|
|
$clauses = [
|
|
'p.p_code like ' . $this->db->escape('%' . $rowData),
|
|
'p.mphone1 = ' . $escapedValue,
|
|
];
|
|
|
|
// Aadhaar lives in the vault, so a typed number matches via its hash.
|
|
$aadhaarHash = (new \App\Libraries\AadhaarVaultService())->hash($rowData);
|
|
if ($aadhaarHash !== '') {
|
|
$clauses[] = 'p.udai_hash = ' . $this->db->escape($aadhaarHash);
|
|
}
|
|
|
|
$cleanDigits = preg_replace('/\D/', '', $rowData) ?? '';
|
|
$targetCols = array_values(array_filter(array_unique(array_merge(
|
|
$abhaField ? [$abhaField] : [],
|
|
$additionalAbhaFields
|
|
))));
|
|
|
|
if (strlen($cleanDigits) === 14) {
|
|
$formatted14 = substr($cleanDigits, 0, 2) . '-' . substr($cleanDigits, 2, 4) . '-' . substr($cleanDigits, 6, 4) . '-' . substr($cleanDigits, 10, 4);
|
|
foreach ($targetCols as $col) {
|
|
$clauses[] = 'p.' . $col . ' = ' . $escapedValue;
|
|
$clauses[] = 'p.' . $col . ' = ' . $this->db->escape($formatted14);
|
|
}
|
|
if ($abhaAddressField !== null && $abhaAddressField !== '') {
|
|
$clauses[] = 'p.' . $abhaAddressField . ' = ' . $escapedValue;
|
|
$clauses[] = 'p.' . $abhaAddressField . ' LIKE ' . $this->db->escape($cleanDigits . '@%');
|
|
}
|
|
} else {
|
|
foreach ($targetCols as $col) {
|
|
$clauses[] = 'p.' . $col . ' = ' . $escapedValue;
|
|
}
|
|
if ($abhaAddressField !== null && $abhaAddressField !== '') {
|
|
$clauses[] = 'p.' . $abhaAddressField . ' = ' . $escapedValue;
|
|
}
|
|
}
|
|
|
|
$oldUhidClause = $this->buildOldUhidSearchClause($rowData, $oldUhidField);
|
|
if ($oldUhidClause !== '') {
|
|
$clauses[] = $oldUhidClause;
|
|
}
|
|
|
|
return ' and (' . implode(' or ', array_unique($clauses)) . ')';
|
|
}
|
|
|
|
public function search_ajax()
|
|
{
|
|
$request = $this->request->getGet();
|
|
|
|
// Get search value from DataTables search box or from initial search_query
|
|
$dtSearch = trim((string) ($request['dt_search'] ?? ($request['search']['value'] ?? '')));
|
|
$initialSearch = trim((string) ($request['search_query'] ?? ''));
|
|
$advSearchBy = trim((string) ($request['adv_search_by'] ?? ''));
|
|
$advSearchValue = trim((string) ($request['adv_search_value'] ?? ''));
|
|
$advAgeMode = trim((string) ($request['adv_age_mode'] ?? ''));
|
|
$advAgeValue = trim((string) ($request['adv_age_value'] ?? ''));
|
|
$advAgeFrom = trim((string) ($request['adv_age_from'] ?? ''));
|
|
$advAgeTo = trim((string) ($request['adv_age_to'] ?? ''));
|
|
$advAgeTolerance = trim((string) ($request['adv_age_tolerance'] ?? ''));
|
|
|
|
$initialSearch = preg_replace('/[^A-Za-z0-9 _.@\/-]/', '', $initialSearch);
|
|
$dtSearch = preg_replace('/[^A-Za-z0-9 _.@\/-]/', '', $dtSearch);
|
|
$advSearchValue = preg_replace('/[^A-Za-z0-9 _.@\/-]/', '', $advSearchValue);
|
|
$advAgeValue = preg_replace('/[^0-9]/', '', $advAgeValue);
|
|
$advAgeFrom = preg_replace('/[^0-9]/', '', $advAgeFrom);
|
|
$advAgeTo = preg_replace('/[^0-9]/', '', $advAgeTo);
|
|
$advAgeTolerance = preg_replace('/[^0-9]/', '', $advAgeTolerance);
|
|
$advAgeToleranceInt = $advAgeTolerance === '' ? 2 : (int) $advAgeTolerance;
|
|
if ($advAgeToleranceInt < 0) {
|
|
$advAgeToleranceInt = 0;
|
|
}
|
|
if ($advAgeToleranceInt > 20) {
|
|
$advAgeToleranceInt = 20;
|
|
}
|
|
|
|
// Detect ABHA and legacy UHID columns in patient_master
|
|
$pmFields = $this->db->getFieldNames('patient_master') ?? [];
|
|
$abhaIdCols = array_values(array_intersect(['abha_id', 'abha_no', 'abha'], $pmFields));
|
|
$hasAbhaAddress = in_array('abha_address', $pmFields, true);
|
|
$abhaField = $abhaIdCols[0] ?? ($hasAbhaAddress ? 'abha_address' : null);
|
|
$oldUhidField = null;
|
|
foreach (['old_uhid', 'legacy_uhid', 'old_patient_code'] as $f) {
|
|
if (in_array($f, $pmFields, true)) { $oldUhidField = $f; break; }
|
|
}
|
|
|
|
$hasAdvancedFilters = false;
|
|
$advancedClause = '';
|
|
|
|
if ($advSearchBy === 'age') {
|
|
$ageExpr = "(CASE
|
|
WHEN p.dob IS NOT NULL AND p.dob != '0000-00-00' THEN TIMESTAMPDIFF(YEAR, p.dob, CURDATE())
|
|
WHEN p.age REGEXP '^[0-9]+$' THEN CAST(p.age AS UNSIGNED)
|
|
ELSE NULL
|
|
END)";
|
|
|
|
if ($advAgeMode === 'between' && $advAgeFrom !== '' && $advAgeTo !== '') {
|
|
$from = (int) $advAgeFrom;
|
|
$to = (int) $advAgeTo;
|
|
if ($from > $to) {
|
|
[$from, $to] = [$to, $from];
|
|
}
|
|
$advancedClause = " and {$ageExpr} between {$from} and {$to}";
|
|
$hasAdvancedFilters = true;
|
|
} elseif ($advAgeMode === 'approx' && $advAgeValue !== '') {
|
|
$age = (int) $advAgeValue;
|
|
$min = max(0, $age - $advAgeToleranceInt);
|
|
$max = $age + $advAgeToleranceInt;
|
|
$advancedClause = " and {$ageExpr} between {$min} and {$max}";
|
|
$hasAdvancedFilters = true;
|
|
}
|
|
} elseif ($advSearchValue !== '') {
|
|
$escapedLike = $this->db->escape('%' . $advSearchValue . '%');
|
|
$escapedExact = $this->db->escape($advSearchValue);
|
|
|
|
switch ($advSearchBy) {
|
|
case 'patient_uhid':
|
|
$advancedClause = " and (p.p_code LIKE {$escapedLike}";
|
|
if (ctype_digit($advSearchValue)) {
|
|
$advancedClause .= ' or p.id = ' . (int) $advSearchValue;
|
|
}
|
|
$advancedClause .= ')';
|
|
$hasAdvancedFilters = true;
|
|
break;
|
|
case 'patient_name':
|
|
$advancedClause = " and p.p_fname LIKE {$escapedLike}";
|
|
$hasAdvancedFilters = true;
|
|
break;
|
|
case 'relative_name':
|
|
$advancedClause = " and p.p_rname LIKE {$escapedLike}";
|
|
$hasAdvancedFilters = true;
|
|
break;
|
|
case 'phone':
|
|
$advancedClause = " and (p.mphone1 = {$escapedExact} or p.mphone1 LIKE {$escapedLike})";
|
|
$hasAdvancedFilters = true;
|
|
break;
|
|
case 'refer_by':
|
|
if (in_array('referby', $pmFields, true)) {
|
|
$advancedClause = " and p.referby LIKE {$escapedLike}";
|
|
$hasAdvancedFilters = true;
|
|
}
|
|
break;
|
|
case 'old_uhid':
|
|
$oldUhidClause = $this->buildOldUhidSearchClause($advSearchValue, $oldUhidField);
|
|
if ($oldUhidClause !== '') {
|
|
$advancedClause = ' and (' . $oldUhidClause . ')';
|
|
$hasAdvancedFilters = true;
|
|
}
|
|
break;
|
|
case 'abha':
|
|
$abhaConds = [];
|
|
$rawDigits = preg_replace('/\D/', '', $advSearchValue);
|
|
$is14Digit = (strlen($rawDigits) === 14);
|
|
$formatted14 = $is14Digit
|
|
? substr($rawDigits, 0, 2) . '-' . substr($rawDigits, 2, 4) . '-' . substr($rawDigits, 6, 4) . '-' . substr($rawDigits, 10, 4)
|
|
: '';
|
|
|
|
foreach ($abhaIdCols as $col) {
|
|
if ($is14Digit) {
|
|
$abhaConds[] = "p.{$col} = " . $this->db->escape($rawDigits);
|
|
$abhaConds[] = "p.{$col} = " . $this->db->escape($formatted14);
|
|
}
|
|
$abhaConds[] = "p.{$col} = {$escapedExact}";
|
|
$abhaConds[] = "p.{$col} LIKE {$escapedLike}";
|
|
}
|
|
|
|
if ($hasAbhaAddress) {
|
|
if ($is14Digit) {
|
|
$abhaConds[] = "p.abha_address = " . $this->db->escape($rawDigits);
|
|
$abhaConds[] = "p.abha_address LIKE " . $this->db->escape($rawDigits . '@%');
|
|
}
|
|
$abhaConds[] = "p.abha_address = {$escapedExact}";
|
|
$abhaConds[] = "p.abha_address LIKE {$escapedLike}";
|
|
}
|
|
|
|
if (!empty($abhaConds)) {
|
|
$advancedClause = ' and (' . implode(' or ', array_unique($abhaConds)) . ')';
|
|
$hasAdvancedFilters = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
$buildTokenClause = function (string $searchValue) use ($abhaField, $oldUhidField, $abhaIdCols, $hasAbhaAddress): string {
|
|
$searchClause = '';
|
|
$sdateArray = explode(' ', $searchValue);
|
|
|
|
foreach ($sdateArray as $rowData) {
|
|
if ($rowData === '') {
|
|
continue;
|
|
}
|
|
|
|
$escapedValue = $this->db->escape($rowData);
|
|
$escapedLike = $this->db->escape('%' . $rowData . '%');
|
|
|
|
if (is_numeric($rowData)) {
|
|
$searchClause .= $this->buildPatientSearchCondition(
|
|
$rowData,
|
|
$abhaField,
|
|
$oldUhidField,
|
|
$abhaIdCols,
|
|
$hasAbhaAddress ? 'abha_address' : null
|
|
);
|
|
} elseif (str_contains($rowData, '@')) {
|
|
// Definite ABHA address or Email address search (e.g. kajolallu2001@sbx)
|
|
$atClauses = [
|
|
'p.email1 = ' . $escapedValue,
|
|
];
|
|
if ($hasAbhaAddress) {
|
|
$atClauses[] = 'p.abha_address = ' . $escapedValue;
|
|
$atClauses[] = 'p.abha_address LIKE ' . $escapedLike;
|
|
}
|
|
foreach ($abhaIdCols as $col) {
|
|
$atClauses[] = "p.{$col} = " . $escapedValue;
|
|
$atClauses[] = "p.{$col} LIKE " . $escapedLike;
|
|
}
|
|
$searchClause .= ' and (' . implode(' or ', array_unique($atClauses)) . ')';
|
|
} elseif (ctype_alpha($rowData)) {
|
|
$legacyUhidClause = '';
|
|
$oldUhidClause = $this->buildOldUhidSearchClause($rowData, $oldUhidField);
|
|
if ($oldUhidClause !== '') {
|
|
$legacyUhidClause = ' or (' . $oldUhidClause . ')';
|
|
}
|
|
$alphaClauses = [
|
|
"p.p_fname LIKE " . $escapedLike,
|
|
"p.email1 = " . $escapedValue,
|
|
"SUBSTRING_INDEX(p.p_fname, ' ', 1) SOUNDS LIKE " . $escapedValue,
|
|
];
|
|
if ($hasAbhaAddress) {
|
|
$alphaClauses[] = "p.abha_address LIKE " . $escapedLike;
|
|
}
|
|
foreach ($abhaIdCols as $col) {
|
|
$alphaClauses[] = "p.{$col} LIKE " . $escapedLike;
|
|
}
|
|
$searchClause .= " and (" . implode(' or ', $alphaClauses) . $legacyUhidClause . ")";
|
|
} else {
|
|
$rawDigits = preg_replace('/\D/', '', $rowData);
|
|
$is14Digit = (strlen($rawDigits) === 14);
|
|
$formatted14 = $is14Digit
|
|
? substr($rawDigits, 0, 2) . '-' . substr($rawDigits, 2, 4) . '-' . substr($rawDigits, 6, 4) . '-' . substr($rawDigits, 10, 4)
|
|
: '';
|
|
|
|
$mixedClauses = [
|
|
"p.p_code LIKE " . $escapedValue,
|
|
"p.p_code LIKE " . $escapedLike,
|
|
"p.p_fname LIKE " . $escapedLike,
|
|
"p.email1 = " . $escapedValue,
|
|
];
|
|
|
|
foreach ($abhaIdCols as $col) {
|
|
if ($is14Digit) {
|
|
$mixedClauses[] = "p.{$col} = " . $this->db->escape($rawDigits);
|
|
$mixedClauses[] = "p.{$col} = " . $this->db->escape($formatted14);
|
|
}
|
|
$mixedClauses[] = "p.{$col} = " . $escapedValue;
|
|
$mixedClauses[] = "p.{$col} LIKE " . $escapedLike;
|
|
}
|
|
|
|
if ($hasAbhaAddress) {
|
|
if ($is14Digit) {
|
|
$mixedClauses[] = "p.abha_address = " . $this->db->escape($rawDigits);
|
|
$mixedClauses[] = "p.abha_address LIKE " . $this->db->escape($rawDigits . '@%');
|
|
}
|
|
$mixedClauses[] = "p.abha_address = " . $escapedValue;
|
|
$mixedClauses[] = "p.abha_address LIKE " . $escapedLike;
|
|
}
|
|
|
|
$oldUhidClause = $this->buildOldUhidSearchClause($rowData, $oldUhidField);
|
|
if ($oldUhidClause !== '') {
|
|
$mixedClauses[] = '(' . $oldUhidClause . ')';
|
|
}
|
|
|
|
$searchClause .= ' and (' . implode(' or ', array_unique($mixedClauses)) . ')';
|
|
}
|
|
}
|
|
|
|
return $searchClause;
|
|
};
|
|
|
|
// Build WHERE clause using base filters first; DataTable search only narrows those results.
|
|
$searchString = ' 1=1 ';
|
|
if ($initialSearch !== '') {
|
|
$searchString .= $buildTokenClause($initialSearch);
|
|
}
|
|
|
|
if ($dtSearch !== '') {
|
|
$searchString .= $buildTokenClause($dtSearch);
|
|
}
|
|
|
|
$searchString .= $advancedClause;
|
|
|
|
$useLatestThirtyOnly = ($initialSearch === '' && $dtSearch === '' && ! $hasAdvancedFilters);
|
|
if ($useLatestThirtyOnly) {
|
|
$searchString .= " and (
|
|
(
|
|
p.last_visit IS NOT NULL
|
|
AND p.last_visit != '0000-00-00'
|
|
AND DATE(p.last_visit) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
|
)
|
|
OR
|
|
(
|
|
p.insert_date IS NOT NULL
|
|
AND p.insert_date != '0000-00-00'
|
|
AND DATE(p.insert_date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
|
)
|
|
)";
|
|
}
|
|
|
|
// Count total records (without filtering)
|
|
$totalSql = "SELECT COUNT(DISTINCT p.id) as total FROM patient_master p";
|
|
$totalData = (int) $this->db->query($totalSql)->getRow()->total;
|
|
|
|
// Count filtered records
|
|
$filteredSql = "SELECT COUNT(DISTINCT p.id) as total FROM patient_master p WHERE $searchString";
|
|
$totalFiltered = (int) $this->db->query($filteredSql)->getRow()->total;
|
|
|
|
// Get pagination and sorting parameters
|
|
$start = isset($request['start']) ? (int) $request['start'] : 0;
|
|
$length = isset($request['length']) ? (int) $request['length'] : 10;
|
|
|
|
// Columns for ordering
|
|
$columns = ['', 'p.p_code', 'p.p_fname', 'p.age', 'p.last_visit', 'p.insurance_id', ''];
|
|
$orderColumn = 'p.last_visit';
|
|
$orderDir = 'DESC';
|
|
|
|
if (!empty($request['order'][0]['column']) && is_numeric($request['order'][0]['column'])) {
|
|
$orderIndex = (int) $request['order'][0]['column'];
|
|
if (isset($columns[$orderIndex]) && $columns[$orderIndex] !== '') {
|
|
$orderColumn = $columns[$orderIndex];
|
|
}
|
|
$orderDir = strtoupper($request['order'][0]['dir'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
|
}
|
|
|
|
// Get data
|
|
$sql = "SELECT p.*,
|
|
Date_Format(p.last_visit,'%d-%m-%Y') AS Last_Visit,
|
|
IF(p.insurance_id = 0 OR p.insurance_id IS NULL, 'Self', 'Insuranced') as insurance_status
|
|
FROM patient_master p
|
|
WHERE $searchString
|
|
GROUP BY p.id
|
|
ORDER BY $orderColumn $orderDir
|
|
LIMIT $start, $length";
|
|
|
|
$records = $this->db->query($sql)->getResult();
|
|
|
|
// Format data for DataTables
|
|
$data = [];
|
|
foreach ($records as $index => $row) {
|
|
$patientId = (int) ($row->id ?? 0);
|
|
$age = esc(get_age_1($row->dob ?? null, $row->age ?? '', $row->age_in_month ?? '', $row->estimate_dob ?? '', $row->Last_Visit ?? null));
|
|
$oldUhid = '';
|
|
$patientNameCell = esc(($row->p_fname ?? '') . ' {' . ($row->p_rname ?? '') . '}');
|
|
$referBy = trim((string) ($row->referby ?? ''));
|
|
if ($referBy !== '') {
|
|
$patientNameCell .= '<br><small><i>Refer By: ' . esc(ucwords(strtolower($referBy))) . '</i></small>';
|
|
}
|
|
if ($oldUhidField !== null && $oldUhidField !== '' && isset($row->{$oldUhidField})) {
|
|
$oldUhid = (string) $row->{$oldUhidField};
|
|
}
|
|
|
|
$abhaDisplay = '';
|
|
$abhaAddr = trim((string) ($row->abha_address ?? ''));
|
|
$abhaNum = trim((string) ($row->abha_id ?? ($row->abha_no ?? ($row->abha ?? ''))));
|
|
$cleanNum = preg_replace('/\D/', '', $abhaNum) ?? '';
|
|
if (strlen($cleanNum) === 14) {
|
|
$abhaNum = substr($cleanNum, 0, 2) . '-' . substr($cleanNum, 2, 4) . '-' . substr($cleanNum, 6, 4) . '-' . substr($cleanNum, 10, 4);
|
|
}
|
|
if ($abhaAddr !== '' && $abhaNum !== '') {
|
|
$abhaDisplay = $abhaAddr . ' (' . $abhaNum . ')';
|
|
} elseif ($abhaAddr !== '') {
|
|
$abhaDisplay = $abhaAddr;
|
|
} elseif ($abhaNum !== '') {
|
|
$abhaDisplay = $abhaNum;
|
|
}
|
|
|
|
$data[] = [
|
|
$start + $index + 1,
|
|
$this->formatPatientCodeCell($patientId, (string) ($row->p_code ?? ''), $oldUhid, $abhaDisplay),
|
|
$patientNameCell,
|
|
$age,
|
|
esc($row->Last_Visit ?? ''),
|
|
esc($row->insurance_status ?? 'Self'),
|
|
'<a href="javascript:load_form(\'' . base_url('billing/patient/show_profile_opd/' . $patientId . '/1') . '\');" class="btn btn-info btn-xs"><span class="fa fa-history"></span> Patient History</a>'
|
|
];
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'draw' => isset($request['draw']) ? (int) $request['draw'] : 0,
|
|
'recordsTotal' => $totalData,
|
|
'recordsFiltered' => $totalFiltered,
|
|
'data' => $data
|
|
]);
|
|
}
|
|
|
|
public function person_record(int $pno, int $edit = 0)
|
|
{
|
|
$sql = "select *,
|
|
Date_Format(insert_date,'%Y-%m-%d') as str_regdate,
|
|
if(gender=1,'Male','Female') as xgender ,
|
|
if(date_add(insert_date,interval 24 hour)>sysdate(),1,0) as p_edit
|
|
from patient_master where id=" . $pno;
|
|
$query = $this->db->query($sql);
|
|
$data['data'] = $query->getResult();
|
|
|
|
if (count($data['data']) === 0) {
|
|
return $this->response->setStatusCode(404)->setBody('Patient not found');
|
|
}
|
|
|
|
if (! empty($data['data'][0])) {
|
|
$curAbha = trim((string) ($data['data'][0]->abha_address ?? ''));
|
|
if ($curAbha === '') {
|
|
$foundAbha = '';
|
|
if ($this->db->tableExists('abdm_link_transactions')) {
|
|
$alt = $this->db->table('abdm_link_transactions')
|
|
->select('abha_address')
|
|
->where('patient_id', $pno)
|
|
->where('abha_address !=', '')
|
|
->orderBy('id', 'DESC')
|
|
->get(1)->getRowArray();
|
|
if (! empty($alt['abha_address'])) {
|
|
$foundAbha = trim((string) $alt['abha_address']);
|
|
}
|
|
}
|
|
if ($foundAbha === '' && $this->db->tableExists('record_links')) {
|
|
$rl = $this->db->table('record_links')
|
|
->select('abha_id')
|
|
->where('care_context_reference LIKE', 'OPD-' . $pno . '-%')
|
|
->where('abha_id !=', '')
|
|
->orderBy('id', 'DESC')
|
|
->get(1)->getRowArray();
|
|
if (! empty($rl['abha_id'])) {
|
|
$foundAbha = trim((string) $rl['abha_id']);
|
|
}
|
|
}
|
|
if ($foundAbha !== '') {
|
|
$data['data'][0]->abha_address = $foundAbha;
|
|
if (str_contains($foundAbha, '@')) {
|
|
$this->db->table('patient_master')->where('id', $pno)->update(['abha_address' => $foundAbha]);
|
|
} else {
|
|
$this->db->table('patient_master')->where('id', $pno)->update(['abha_id' => $foundAbha]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$required_age = $data['data'][0]->age;
|
|
$required_age_in_month = $data['data'][0]->age_in_month;
|
|
$required_dob = $data['data'][0]->dob;
|
|
$estimate_dob = $data['data'][0]->estimate_dob;
|
|
|
|
if ($required_age == '' && $estimate_dob == 1) {
|
|
if ($required_age == '0') {
|
|
if ($required_age_in_month == 0 || $required_age_in_month == '') {
|
|
$edit = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($required_dob == '' && $estimate_dob == 0) {
|
|
$edit = 1;
|
|
}
|
|
|
|
$sql = "select o.opd_id,o.opd_code,o.doc_name,o.apointment_date,o.p_id,
|
|
p.p_fname,if(o.apointment_date=curdate(),1,0) as new_opd,
|
|
date_format(o.apointment_date,'%d-%m-%Y') as str_apointment_date
|
|
from opd_master o join patient_master p on o.p_id=p.id
|
|
where o.p_id=$pno
|
|
order by o.opd_id desc
|
|
limit 200";
|
|
$query = $this->db->query($sql);
|
|
$data['opd_List'] = $query->getResult();
|
|
|
|
$sql = "select m.id,m.invoice_code,Date_Format(m.inv_date,'%d-%m-%y') as str_inv_date,m.inv_name,
|
|
m.attach_id,m.insurance_card_id,
|
|
if(count(t.id)>0,group_concat(t.item_name SEPARATOR ' / '),'No-Item') as Item_List, m.net_amount,m.invoice_status
|
|
from invoice_master m left join invoice_item t on m.id=t.inv_master_id
|
|
where attach_type=0 and attach_id=$pno
|
|
group by m.id
|
|
order by m.id desc
|
|
limit 200";
|
|
$query = $this->db->query($sql);
|
|
$data['invoice_list'] = $query->getResult();
|
|
|
|
$sql = "select i.*,m.ins_company_name,m.opd_allowed,m.charge_cash
|
|
from hc_insurance_card i join hc_insurance m on i.insurance_id=m.id
|
|
where i.p_id=$pno";
|
|
$query = $this->db->query($sql);
|
|
$data['data_insurance_card'] = $query->getResult();
|
|
|
|
$sql = "select *,Date_Format(date_registration,'%d-%m-%Y') as str_date_registration
|
|
from organization_case_master where status=0 and case_type=0 and p_id=$pno";
|
|
$query = $this->db->query($sql);
|
|
$data['case_master_opd'] = $query->getResult();
|
|
|
|
$sql = "SELECT o.*,DATE_FORMAT(o.date_registration,'%d-%m-%Y') as str_date_registration ,
|
|
i.ipd_code,i.register_date
|
|
from organization_case_master o left JOIN ipd_master i ON o.ipd_id=i.id
|
|
WHERE o.status=0 AND o.case_type=1 and o.p_id=$pno";
|
|
$query = $this->db->query($sql);
|
|
$data['case_master_ipd'] = $query->getResult();
|
|
|
|
$sql = "select * from file_upload_data where id=" . $data['data'][0]->profile_file_id;
|
|
$query = $this->db->query($sql);
|
|
$file_data = $query->getResult();
|
|
|
|
$profile_file_path = '/assets/images/no_image.svg';
|
|
$profile_picture_path = '';
|
|
$abha_profile_photo_base64 = '';
|
|
|
|
if ($this->db->fieldExists('profile_picture', 'patient_master')) {
|
|
$profile_picture_path = (string) ($data['data'][0]->profile_picture ?? '');
|
|
}
|
|
if ($this->db->fieldExists('abha_profile_photo_base64', 'patient_master')) {
|
|
$abha_profile_photo_base64 = trim((string) ($data['data'][0]->abha_profile_photo_base64 ?? ''));
|
|
}
|
|
|
|
$sql = "SELECT * from blood_group order by id";
|
|
$query = $this->db->query($sql);
|
|
$data['blood_group'] = $query->getResult();
|
|
|
|
$data['refer_master'] = $this->getActiveReferMasters();
|
|
|
|
$sql = "select * from tag_master Order by tag_name";
|
|
$query = $this->db->query($sql);
|
|
$data['tag_master'] = $query->getResult();
|
|
|
|
$sql = "SELECT a.*,t.tag_name,t.tag_type_id
|
|
FROM patient_tag_assign a JOIN tag_master t ON a.tag_id=t.id
|
|
WHERE isdelete=0 and a.p_id=$pno";
|
|
$query = $this->db->query($sql);
|
|
$data['patient_tag_list'] = $query->getResult();
|
|
|
|
if ($abha_profile_photo_base64 !== '') {
|
|
$profile_file_path = str_starts_with($abha_profile_photo_base64, 'data:image')
|
|
? $abha_profile_photo_base64
|
|
: 'data:image/jpeg;base64,' . $abha_profile_photo_base64;
|
|
} elseif (count($file_data) > 0) {
|
|
$pos = strpos($file_data[0]->full_path, '/uploads/', 1);
|
|
if ($pos !== false) {
|
|
$profile_file_path = substr($file_data[0]->full_path, $pos);
|
|
} elseif (!empty($file_data[0]->full_path)) {
|
|
$profile_file_path = $file_data[0]->full_path;
|
|
}
|
|
} elseif ($profile_picture_path !== '') {
|
|
$pos = strpos($profile_picture_path, '/uploads/', 1);
|
|
if ($pos !== false) {
|
|
$profile_file_path = substr($profile_picture_path, $pos);
|
|
} else {
|
|
$profile_file_path = $profile_picture_path;
|
|
}
|
|
}
|
|
|
|
$data['profile_file_path'] = $profile_file_path;
|
|
$data['abhaPhotoAvailable'] = $abha_profile_photo_base64 !== '';
|
|
|
|
if ($edit == 0) {
|
|
return view('billing/Person_profile_V', $data);
|
|
}
|
|
|
|
return view('billing/Person_Edit_V', $data);
|
|
}
|
|
|
|
public function update()
|
|
{
|
|
if (!$this->request->isAJAX()) {
|
|
return $this->response
|
|
->setStatusCode(400)
|
|
->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$chk_age = $this->request->getPost('chk_age');
|
|
$pid = (int) $this->request->getPost('p_id');
|
|
$abhaIdInput = trim((string) $this->request->getPost('input_abha_id'));
|
|
$abhaAddressInput = trim((string) $this->request->getPost('input_abha_address'));
|
|
[$abhaId, $abhaAddress, $abhaError] = $this->normalizeAbhaInputs($abhaIdInput, $abhaAddressInput);
|
|
$age_month = (string) $this->request->getPost('input_age_month');
|
|
$age_year = (string) $this->request->getPost('input_age_year');
|
|
$patientModel = new PatientModel();
|
|
$existingPatient = $pid > 0 ? $patientModel->find($pid) : null;
|
|
$isAbhaVerifiedLocked = is_array($existingPatient)
|
|
&& trim((string) ($existingPatient['abha_id'] ?? $existingPatient['abha_no'] ?? $existingPatient['abha'] ?? $existingPatient['abha_address'] ?? '')) !== ''
|
|
&& strtoupper(trim((string) ($existingPatient['abha_verified_status'] ?? ''))) === 'VERIFIED';
|
|
|
|
$rules = [
|
|
'input_name' => 'required|min_length[1]|max_length[30]',
|
|
];
|
|
|
|
if ($chk_age === 'on') {
|
|
$estimate_dob = 1;
|
|
if ($age_year === '' && $age_month === '') {
|
|
$rules['input_age_year'] = 'required|min_length[1]|max_length[4]';
|
|
}
|
|
} else {
|
|
$estimate_dob = 0;
|
|
$rules['datepicker_dob'] = 'required|min_length[1]|max_length[10]';
|
|
}
|
|
|
|
$validation = service('validation');
|
|
$validation->setRules($rules);
|
|
if (!$validation->withRequest($this->request)->run()) {
|
|
$errorText = implode("\n", $validation->getErrors());
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => $errorText,
|
|
]);
|
|
}
|
|
|
|
if ($abhaError !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => $abhaError,
|
|
]);
|
|
}
|
|
|
|
$referByName = trim((string) $this->request->getPost('refer_by_name'));
|
|
$existingReferBy = is_array($existingPatient) ? (string) ($existingPatient['referby'] ?? '') : null;
|
|
$referByError = $this->validatePatientReferByInput($referByName, $existingReferBy);
|
|
if ($referByError !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => $referByError,
|
|
]);
|
|
}
|
|
|
|
// Enforce unique ABHA ID and Address across patients (excluding current patient)
|
|
if ($abhaId !== '' || $abhaAddress !== '') {
|
|
$conflictAbha = \App\Libraries\Abdm\AbhaDemographicValidator::checkUniqueness($abhaId, $abhaAddress, $pid, $this->db);
|
|
if ($conflictAbha !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => $conflictAbha['error_text'],
|
|
]);
|
|
}
|
|
|
|
// Validate demographics if known ABHA profile exists
|
|
$knownProfile = $this->resolveKnownAbhaProfile($abhaId, $abhaAddress);
|
|
if (! empty($knownProfile)) {
|
|
$candidatePatient = [
|
|
'p_fname' => (string) $this->request->getPost('input_name'),
|
|
'gender' => $this->request->getPost('optionsRadios_gender'),
|
|
'dob' => $this->request->getPost('datepicker_dob'),
|
|
'age' => $this->request->getPost('input_age_year'),
|
|
'mphone1' => $this->request->getPost('input_mphone1'),
|
|
];
|
|
$demoError = \App\Libraries\Abdm\AbhaDemographicValidator::validateDemographicsMatch($candidatePatient, $knownProfile);
|
|
if ($demoError !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => $demoError,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enforce unique Aadhaar Number across patients (excluding current patient)
|
|
$aadhaarVault = new \App\Libraries\AadhaarVaultService();
|
|
$aadhaarInput = trim((string) $this->request->getPost('input_Aadhar'));
|
|
if ($aadhaarInput !== '' && ! $aadhaarVault->isMasked($aadhaarInput)) {
|
|
$digits = $aadhaarVault->normalize($aadhaarInput);
|
|
if ($digits !== '') {
|
|
$conflictAadhaar = $this->findPatientByAadhaar($digits, $pid);
|
|
if ($conflictAadhaar !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'This Aadhaar Number is already linked to patient ' . ($conflictAadhaar['p_code'] ?? '') . ' (' . ($conflictAadhaar['p_fname'] ?? '') . '). Aadhaar Number must be unique to one person.',
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
$data = [
|
|
'mphone1' => $this->request->getPost('input_mphone1'),
|
|
'p_fname' => strtoupper((string) $this->request->getPost('input_name')),
|
|
'gender' => $this->request->getPost('optionsRadios_gender'),
|
|
'zip' => $this->request->getPost('input_zip'),
|
|
'add1' => strtoupper((string) $this->request->getPost('input_address')),
|
|
'city' => strtoupper((string) $this->request->getPost('input_city')),
|
|
'district' => strtoupper((string) $this->request->getPost('input_district')),
|
|
'state' => strtoupper((string) $this->request->getPost('input_state')),
|
|
'title' => $this->request->getPost('cbo_title'),
|
|
'p_relative' => strtoupper((string) $this->request->getPost('cbo_relation')),
|
|
'p_rname' => strtoupper((string) $this->request->getPost('input_relative_name')),
|
|
'email1' => strtoupper((string) $this->request->getPost('input_email')),
|
|
'estimate_dob' => $estimate_dob,
|
|
'blood_group' => $this->request->getPost('input_blood_group'),
|
|
];
|
|
if (! $aadhaarVault->isMasked($aadhaarInput)) {
|
|
$data += $aadhaarVault->buildColumns($aadhaarInput);
|
|
}
|
|
$this->applyPatientReferbyField($data, $referByName);
|
|
|
|
if ($isAbhaVerifiedLocked) {
|
|
$data['p_fname'] = (string) ($existingPatient['p_fname'] ?? $data['p_fname']);
|
|
$data['gender'] = (string) ($existingPatient['gender'] ?? $data['gender']);
|
|
$data['dob'] = $existingPatient['dob'] ?? ($data['dob'] ?? null);
|
|
if (array_key_exists('age', $existingPatient)) {
|
|
$data['age'] = $existingPatient['age'];
|
|
}
|
|
if (array_key_exists('age_in_month', $existingPatient)) {
|
|
$data['age_in_month'] = $existingPatient['age_in_month'];
|
|
}
|
|
if (array_key_exists('estimate_dob', $existingPatient)) {
|
|
$data['estimate_dob'] = $existingPatient['estimate_dob'];
|
|
}
|
|
$abhaId = trim((string) ($existingPatient['abha_id'] ?? $existingPatient['abha_no'] ?? $existingPatient['abha'] ?? $existingPatient['abha_address'] ?? $abhaId));
|
|
}
|
|
|
|
$this->applyPatientAbhaFieldValues($data, $abhaId, $abhaAddress);
|
|
|
|
if ($chk_age === 'on') {
|
|
$data['age'] = $age_year;
|
|
$data['age_in_month'] = $age_month;
|
|
} else {
|
|
$data['dob'] = $this->parseDate($this->request->getPost('datepicker_dob'));
|
|
}
|
|
|
|
$patientModel->updatePatient($data, $pid);
|
|
$this->saveNamesToNameList([
|
|
(string) $this->request->getPost('input_name'),
|
|
(string) $this->request->getPost('input_relative_name'),
|
|
]);
|
|
|
|
$this->enqueuePatientAbhaSync($pid, $data, 'patient.updated');
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'showcontent' => 'Data Saved successfully',
|
|
]);
|
|
}
|
|
|
|
private function getActiveReferMasters(): array
|
|
{
|
|
if (! $this->db->tableExists('refer_master')) {
|
|
return [];
|
|
}
|
|
|
|
return $this->db->table('refer_master')
|
|
->where('active', 1)
|
|
->orderBy('f_name', 'ASC')
|
|
->get()
|
|
->getResult();
|
|
}
|
|
|
|
private function applyPatientReferbyField(array &$data, string $referByName): void
|
|
{
|
|
if (! $this->db->fieldExists('referby', 'patient_master')) {
|
|
return;
|
|
}
|
|
|
|
$data['referby'] = trim(strtoupper($referByName));
|
|
}
|
|
|
|
private function validatePatientReferByInput(string $referByName, ?string $existingReferBy = null): ?string
|
|
{
|
|
$referByName = trim($referByName);
|
|
if ($referByName === '') {
|
|
return null;
|
|
}
|
|
|
|
$normalize = static fn (string $v): string => strtoupper(trim(preg_replace('/\s+/', ' ', $v)));
|
|
$typed = $normalize($referByName);
|
|
if ($typed === '') {
|
|
return null;
|
|
}
|
|
|
|
if ($existingReferBy !== null && $normalize($existingReferBy) === $typed) {
|
|
return null;
|
|
}
|
|
|
|
$activeRefs = $this->getActiveReferMasters();
|
|
foreach ($activeRefs as $row) {
|
|
$label = trim((string) (($row->title ?? '') . ' ' . ($row->f_name ?? '')));
|
|
if ($label !== '' && $normalize($label) === $typed) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return 'Refer By must be selected from Refer Master list.';
|
|
}
|
|
|
|
public function update_aadhar()
|
|
{
|
|
if (!$this->request->isAJAX()) {
|
|
return $this->response
|
|
->setStatusCode(400)
|
|
->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$pid = (int) $this->request->getPost('p_id');
|
|
$udai = (string) $this->request->getPost('udai');
|
|
|
|
$vault = new \App\Libraries\AadhaarVaultService();
|
|
if ($vault->isMasked($udai)) {
|
|
return $this->response->setJSON(['update' => 1, 'showcontent' => 'Aadhaar unchanged']);
|
|
}
|
|
if (trim($udai) !== '') {
|
|
$digits = $vault->normalize($udai);
|
|
if ($digits === '') {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Enter a valid 12-digit Aadhaar number']);
|
|
}
|
|
|
|
$conflictAadhaar = $this->findPatientByAadhaar($digits, $pid);
|
|
if ($conflictAadhaar !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'This Aadhaar Number is already linked to patient ' . ($conflictAadhaar['p_code'] ?? '') . ' (' . ($conflictAadhaar['p_fname'] ?? '') . '). Aadhaar Number must be unique to one person.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
$patientModel = new PatientModel();
|
|
$patientModel->updatePatient($vault->buildColumns($udai), $pid);
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'showcontent' => 'Data Saved successfully',
|
|
]);
|
|
}
|
|
|
|
public function update_abha()
|
|
{
|
|
if (!$this->request->isAJAX()) {
|
|
return $this->response
|
|
->setStatusCode(400)
|
|
->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$pid = (int) $this->request->getPost('p_id');
|
|
$abhaIdInput = trim((string) $this->request->getPost('abha_id'));
|
|
$abhaAddressInput = trim((string) $this->request->getPost('abha_address'));
|
|
[$abhaId, $abhaAddress, $abhaError] = $this->normalizeAbhaInputs($abhaIdInput, $abhaAddressInput);
|
|
$verified = (int) $this->request->getPost('verified'); // 1 = came from OTP flow
|
|
|
|
if ($abhaError !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => $abhaError,
|
|
]);
|
|
}
|
|
|
|
$data = [];
|
|
$this->applyPatientAbhaFieldValues($data, $abhaId, $abhaAddress);
|
|
if ($data === []) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'ABHA field not found in patient master.',
|
|
]);
|
|
}
|
|
|
|
// ABHA ID and Address must be unique across patient_master — block if another
|
|
// patient already carries this exact ABHA ID or address.
|
|
if ($abhaId !== '' || $abhaAddress !== '') {
|
|
$conflict = \App\Libraries\Abdm\AbhaDemographicValidator::checkUniqueness($abhaId, $abhaAddress, $pid, $this->db);
|
|
if ($conflict !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => $conflict['error_text'],
|
|
]);
|
|
}
|
|
|
|
// Enforce demographic matching if known ABHA profile exists
|
|
$patientModel = new PatientModel();
|
|
$patRow = $patientModel->find($pid);
|
|
if (is_array($patRow)) {
|
|
$knownProfile = $this->resolveKnownAbhaProfile($abhaId, $abhaAddress);
|
|
if (! empty($knownProfile)) {
|
|
$demoError = \App\Libraries\Abdm\AbhaDemographicValidator::validateDemographicsMatch($patRow, $knownProfile);
|
|
if ($demoError !== null) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => $demoError,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set abdm_linked_at when ABHA is verified via OTP; clear when manually entered
|
|
$pmFields = $this->db->getFieldNames('patient_master') ?? [];
|
|
if (in_array('abdm_linked_at', $pmFields, true)) {
|
|
$data['abdm_linked_at'] = ($verified === 1 && $abhaId !== '') ? date('Y-m-d H:i:s') : null;
|
|
}
|
|
|
|
$patientModel = new PatientModel();
|
|
$patientModel->updatePatient($data, $pid);
|
|
$this->enqueuePatientAbhaSync($pid, $data, 'patient.abha.updated');
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'showcontent' => 'Data Saved successfully',
|
|
'verified' => $verified === 1 && $abhaId !== '' ? 1 : 0,
|
|
]);
|
|
}
|
|
|
|
public function update_card()
|
|
{
|
|
if (!$this->request->isAJAX()) {
|
|
return $this->response
|
|
->setStatusCode(400)
|
|
->setJSON(['insertid' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
$rules = [
|
|
'input_insurance_id' => 'required|min_length[2]|max_length[30]',
|
|
'input_card_holder_name' => 'required|min_length[1]|max_length[30]',
|
|
];
|
|
|
|
$validation = service('validation');
|
|
$validation->setRules($rules);
|
|
if (!$validation->withRequest($this->request)->run()) {
|
|
$errorText = implode("\n", $validation->getErrors());
|
|
return $this->response->setJSON([
|
|
'insertid' => 0,
|
|
'error_text' => $errorText,
|
|
]);
|
|
}
|
|
|
|
$pid = (int) $this->request->getPost('p_id');
|
|
$insCompanyId = (int) $this->request->getPost('Insurance_id');
|
|
$cardId = (int) $this->request->getPost('inscard_id');
|
|
$issueDate = $this->parseDate($this->request->getPost('datepicker_issue_date'));
|
|
$expiryDate = $this->parseDate($this->request->getPost('datepicker_expiry_date'));
|
|
|
|
$cardData = [
|
|
'insurance_id' => $insCompanyId,
|
|
'p_id' => $pid,
|
|
'insurance_no' => (string) $this->request->getPost('input_insurance_id'),
|
|
'card_holder_name' => strtoupper((string) $this->request->getPost('input_card_holder_name')),
|
|
'issue_date' => $issueDate,
|
|
'expiry_date' => $expiryDate,
|
|
'relation_patient_cardholder' => strtoupper((string) $this->request->getPost('input_Relation')),
|
|
];
|
|
|
|
$patientModel = new PatientModel();
|
|
$insertId = $cardId;
|
|
if ($cardId <= 0) {
|
|
$insertId = $patientModel->insertCard($cardData);
|
|
if ($insertId <= 0) {
|
|
return $this->response->setJSON([
|
|
'insertid' => 0,
|
|
'error_text' => 'Unable to save insurance card.',
|
|
]);
|
|
}
|
|
} else {
|
|
$patientModel->updateCard($cardData, $cardId);
|
|
}
|
|
|
|
$patientUpdate = [
|
|
'insurance_card_id' => $insertId,
|
|
'insurance_id' => $insCompanyId,
|
|
'insurance_no' => $cardData['insurance_no'],
|
|
'card_holder_name' => $cardData['card_holder_name'],
|
|
'issue_date' => $issueDate,
|
|
'expiry_date' => $expiryDate,
|
|
'relation_patient_cardholder' => $cardData['relation_patient_cardholder'],
|
|
];
|
|
$patientModel->updatePatient($patientUpdate, $pid);
|
|
|
|
return $this->response->setJSON([
|
|
'insertid' => $insertId,
|
|
]);
|
|
}
|
|
|
|
public function show_cards(int $pno, int $insId = 0)
|
|
{
|
|
$patient = $this->db->table('patient_master')->where('id', $pno)->get()->getRow();
|
|
if (!$patient) {
|
|
return $this->response->setStatusCode(404)->setBody('Patient not found');
|
|
}
|
|
|
|
$cards = $this->db->table('hc_insurance_card c')
|
|
->select('c.*, i.ins_company_name')
|
|
->join('hc_insurance i', 'i.id = c.insurance_id', 'left')
|
|
->where('c.p_id', $pno)
|
|
->orderBy('c.id', 'DESC')
|
|
->get()
|
|
->getResult();
|
|
|
|
$insList = $this->db->table('hc_insurance')
|
|
->orderBy('ins_company_name', 'ASC')
|
|
->get()
|
|
->getResult();
|
|
|
|
return view('billing/Patient_Cards_V', [
|
|
'patient' => $patient,
|
|
'cards' => $cards,
|
|
'insList' => $insList,
|
|
'selectedInsId' => $insId,
|
|
]);
|
|
}
|
|
|
|
public function show_profile_image(int $pno, int $edit = 0)
|
|
{
|
|
$patient = $this->db->table('patient_master')->where('id', $pno)->get()->getRow();
|
|
if (!$patient) {
|
|
return $this->response->setStatusCode(404)->setBody('Patient not found');
|
|
}
|
|
|
|
$profileFilePath = $this->getProfileFilePath((int) ($patient->profile_file_id ?? 0));
|
|
$hospitalEnabled = hospital_setting_value('ALLOW_IMAGE_PREUPLOAD_EDIT', '0') === '1';
|
|
$user = function_exists('auth') ? auth()->user() : null;
|
|
$userAllowed = $user && method_exists($user, 'can') && $user->can('media.image.preupload-edit');
|
|
|
|
return view('billing/Patient_Profile_Image_V', [
|
|
'patient' => $patient,
|
|
'profileFilePath' => $profileFilePath,
|
|
'allow_image_preupload_edit' => ($hospitalEnabled && $userAllowed) ? 1 : 0,
|
|
]);
|
|
}
|
|
|
|
public function show_profile_opd(int $pno, int $edit = 0)
|
|
{
|
|
$patient = $this->db->table('patient_master')->where('id', $pno)->get()->getRow();
|
|
if (!$patient) {
|
|
return $this->response->setStatusCode(404)->setBody('Patient not found');
|
|
}
|
|
|
|
$profileFilePath = $this->getProfileFilePath((int) ($patient->profile_file_id ?? 0));
|
|
$hospitalEnabled = hospital_setting_value('ALLOW_IMAGE_PREUPLOAD_EDIT', '0') === '1';
|
|
$user = function_exists('auth') ? auth()->user() : null;
|
|
$userAllowed = $user && method_exists($user, 'can') && $user->can('media.image.preupload-edit');
|
|
|
|
$request = service('request');
|
|
$backUrl = trim((string) $request->getGet('back_url'));
|
|
$backTitle = trim((string) $request->getGet('back_title'));
|
|
if ($backUrl === '') {
|
|
$backUrl = base_url('billing/patient/person_record') . '/' . $pno . '/0';
|
|
}
|
|
if ($backTitle === '') {
|
|
$backTitle = 'Profile';
|
|
}
|
|
|
|
$opdFields = $this->db->getFieldNames('opd_master') ?? [];
|
|
$opdSelect = [
|
|
'opd_id',
|
|
'opd_no',
|
|
'opd_code',
|
|
'doc_id',
|
|
'doc_name',
|
|
'apointment_date',
|
|
];
|
|
$opdSelect[] = in_array('queue_no', $opdFields, true) ? 'queue_no' : "'' AS queue_no";
|
|
|
|
$opdList = $this->db->table('opd_master')
|
|
->select(implode(', ', $opdSelect), false)
|
|
->where('p_id', $pno)
|
|
->orderBy('opd_id', 'DESC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$totalOpdVisits = count($opdList);
|
|
$lastVisitDate = '';
|
|
$lastVisitOpdNo = '';
|
|
if ($totalOpdVisits > 0 && !empty($opdList[0]['apointment_date'])) {
|
|
$lastVisitDate = (string) $opdList[0]['apointment_date'];
|
|
}
|
|
if ($totalOpdVisits > 0) {
|
|
$lastVisitOpdNo = trim((string) (($opdList[0]['opd_no'] ?? '') !== '' ? $opdList[0]['opd_no'] : ($opdList[0]['opd_code'] ?? '')));
|
|
}
|
|
|
|
$opdIds = array_column($opdList, 'opd_id');
|
|
$filesByOpd = [];
|
|
$rxByOpd = [];
|
|
|
|
if ($opdIds && $this->db->tableExists('file_upload_data')) {
|
|
$fields = $this->db->getFieldNames('file_upload_data') ?? [];
|
|
$builder = $this->db->table('file_upload_data');
|
|
if (in_array('opd_id', $fields, true)) {
|
|
$builder->whereIn('opd_id', $opdIds);
|
|
} elseif (in_array('attach_id', $fields, true)) {
|
|
$builder->whereIn('attach_id', $opdIds);
|
|
}
|
|
if (in_array('show_type', $fields, true)) {
|
|
$builder->where('show_type', 0);
|
|
}
|
|
|
|
$rows = $builder->orderBy('id', 'ASC')->get()->getResultArray();
|
|
foreach ($rows as $row) {
|
|
$opdId = (int) ($row['opd_id'] ?? ($row['attach_id'] ?? 0));
|
|
if ($opdId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$path = (string) ($row['full_path'] ?? '');
|
|
if ($path !== '') {
|
|
$pos = strpos($path, '/uploads/', 1);
|
|
if ($pos !== false) {
|
|
$path = substr($path, $pos);
|
|
}
|
|
}
|
|
|
|
$ext = strtolower((string) ($row['file_ext'] ?? pathinfo($path, PATHINFO_EXTENSION)));
|
|
$ext = $ext && $ext[0] !== '.' ? '.' . $ext : $ext;
|
|
$isPdf = $ext === '.pdf';
|
|
|
|
$filesByOpd[$opdId][] = [
|
|
'path' => $path,
|
|
'isPdf' => $isPdf,
|
|
];
|
|
}
|
|
}
|
|
|
|
if ($opdIds && $this->db->tableExists('opd_prescription')) {
|
|
$rxFields = $this->db->getFieldNames('opd_prescription') ?? [];
|
|
$selectParts = [];
|
|
|
|
if (in_array('id', $rxFields, true)) {
|
|
$selectParts[] = 'id';
|
|
}
|
|
if (in_array('opd_id', $rxFields, true)) {
|
|
$selectParts[] = 'opd_id';
|
|
}
|
|
foreach (['date_opd_visit', 'queue_no', 'bp', 'diastolic', 'pulse', 'temp', 'spo2', 'complaints', 'diagnosis', 'investigation', 'advice'] as $col) {
|
|
if (in_array($col, $rxFields, true)) {
|
|
$selectParts[] = $col;
|
|
}
|
|
}
|
|
|
|
if ($selectParts !== [] && in_array('opd_id', $rxFields, true)) {
|
|
$rxRows = $this->db->table('opd_prescription')
|
|
->select(implode(',', $selectParts), false)
|
|
->whereIn('opd_id', $opdIds)
|
|
->orderBy('id', 'DESC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($rxRows as $rxRow) {
|
|
$opdId = (int) ($rxRow['opd_id'] ?? 0);
|
|
if ($opdId <= 0 || isset($rxByOpd[$opdId])) {
|
|
continue;
|
|
}
|
|
$rxByOpd[$opdId] = $rxRow;
|
|
}
|
|
}
|
|
}
|
|
|
|
$opdGroups = [];
|
|
foreach ($opdList as $row) {
|
|
$opdId = (int) $row['opd_id'];
|
|
$rx = $rxByOpd[$opdId] ?? null;
|
|
$rxSessionId = (int) (($rx['id'] ?? 0));
|
|
|
|
$opdGroups[] = [
|
|
'opd_id' => $opdId,
|
|
'opd_code' => $row['opd_code'] ?? '',
|
|
'doc_name' => $row['doc_name'] ?? '',
|
|
'opd_date' => !empty($row['apointment_date']) ? date('d-m-Y', strtotime($row['apointment_date'])) : '',
|
|
'queue_no' => !empty($row['queue_no']) ? ('Q:' . $row['queue_no']) : '',
|
|
'rx_session_id' => $rxSessionId,
|
|
'rx_date' => !empty($rx['date_opd_visit']) ? date('d-m-Y', strtotime((string) $rx['date_opd_visit'])) : '',
|
|
'rx_queue_no' => !empty($rx['queue_no']) ? ('Q:' . $rx['queue_no']) : '',
|
|
'bp' => (string) ($rx['bp'] ?? ''),
|
|
'diastolic' => (string) ($rx['diastolic'] ?? ''),
|
|
'pulse' => (string) ($rx['pulse'] ?? ''),
|
|
'temp' => (string) ($rx['temp'] ?? ''),
|
|
'spo2' => (string) ($rx['spo2'] ?? ''),
|
|
'complaints' => (string) ($rx['complaints'] ?? ''),
|
|
'diagnosis' => (string) ($rx['diagnosis'] ?? ''),
|
|
'investigation' => (string) ($rx['investigation'] ?? ''),
|
|
'advice' => (string) ($rx['advice'] ?? ''),
|
|
'files' => $filesByOpd[$opdId] ?? [],
|
|
];
|
|
}
|
|
|
|
$patientDocuments = [];
|
|
if ($this->db->tableExists('file_upload_data')) {
|
|
$fields = $this->db->getFieldNames('file_upload_data') ?? [];
|
|
$builder = $this->db->table('file_upload_data');
|
|
if (in_array('pid', $fields, true) && $opdIds) {
|
|
$builder->groupStart()
|
|
->where('pid', $pno)
|
|
->orWhereIn('opd_id', $opdIds)
|
|
->groupEnd();
|
|
} elseif (in_array('pid', $fields, true)) {
|
|
$builder->where('pid', $pno);
|
|
} elseif ($opdIds && in_array('opd_id', $fields, true)) {
|
|
$builder->whereIn('opd_id', $opdIds);
|
|
}
|
|
|
|
if (in_array('show_type', $fields, true)) {
|
|
$builder->where('show_type', 0);
|
|
}
|
|
|
|
$docRows = $builder->orderBy('id', 'DESC')->get()->getResultArray();
|
|
$fileIds = array_column($docRows, 'id');
|
|
|
|
$syncMap = [];
|
|
if (! empty($fileIds) && $this->db->tableExists('abdm_sync_records')) {
|
|
$localIds = array_map(static fn($id) => 'file-upload-' . $id, $fileIds);
|
|
$syncRows = $this->db->table('abdm_sync_records')
|
|
->whereIn('local_record_id', $localIds)
|
|
->get()
|
|
->getResultArray();
|
|
foreach ($syncRows as $sRow) {
|
|
$rawId = str_replace(['file-upload-', 'file-'], '', (string) ($sRow['local_record_id'] ?? ''));
|
|
$syncMap[(int) $rawId] = $sRow;
|
|
}
|
|
}
|
|
|
|
$taskMap = [];
|
|
if (! empty($fileIds) && $this->db->tableExists('abdm_work_tasks')) {
|
|
$taskRows = $this->db->table('abdm_work_tasks')
|
|
->where('source_module', 'file_upload_data')
|
|
->whereIn('entity_id', array_map('strval', $fileIds))
|
|
->get()
|
|
->getResultArray();
|
|
foreach ($taskRows as $tRow) {
|
|
$taskMap[(int) ($tRow['entity_id'] ?? 0)] = $tRow;
|
|
}
|
|
}
|
|
|
|
foreach ($docRows as $row) {
|
|
$docId = (int) ($row['id'] ?? 0);
|
|
$path = (string) ($row['full_path'] ?? $row['public_path'] ?? '');
|
|
if ($path !== '') {
|
|
$pos = strpos(str_replace('\\', '/', $path), '/uploads/');
|
|
if ($pos !== false) {
|
|
$path = substr(str_replace('\\', '/', $path), $pos);
|
|
}
|
|
}
|
|
|
|
$ext = strtolower(ltrim((string) ($row['file_ext'] ?? pathinfo($path, PATHINFO_EXTENSION)), '.'));
|
|
$isPdf = $ext === 'pdf';
|
|
$docType = trim((string) ($row['document_type'] ?? $row['scan_type'] ?? $row['content_description'] ?? 'Paper Document (General)'));
|
|
if ($docType === '' || strcasecmp($docType, 'Queued for AI analysis') === 0) {
|
|
$docType = 'Paper Document (General)';
|
|
}
|
|
|
|
$hasSync = isset($syncMap[$docId]);
|
|
$hasTask = isset($taskMap[$docId]);
|
|
$fhirStatus = $hasSync ? 'FHIR Shared' : ($hasTask ? 'FHIR Ready' : 'FHIR Available');
|
|
|
|
$patientDocuments[] = [
|
|
'id' => $docId,
|
|
'title' => $docType,
|
|
'document_type' => $docType,
|
|
'path' => $path,
|
|
'isPdf' => $isPdf,
|
|
'ext' => $ext,
|
|
'insertDate' => ! empty($row['insert_date']) ? date('d-m-Y H:i', strtotime((string) $row['insert_date'])) : '',
|
|
'uploadedBy' => (string) ($row['upload_by'] ?? ''),
|
|
'hasSync' => $hasSync,
|
|
'hasTask' => $hasTask,
|
|
'fhirStatus' => $fhirStatus,
|
|
];
|
|
}
|
|
}
|
|
|
|
return view('billing/Patient_Profile_Opd_V', [
|
|
'patient' => $patient,
|
|
'profileFilePath' => $profileFilePath,
|
|
'allow_image_preupload_edit' => 1,
|
|
'opdGroups' => $opdGroups,
|
|
'patientDocuments' => $patientDocuments,
|
|
'totalOpdVisits' => $totalOpdVisits,
|
|
'lastVisitDate' => $lastVisitDate,
|
|
'lastVisitOpdNo' => $lastVisitOpdNo,
|
|
'backUrl' => $backUrl,
|
|
'backTitle' => $backTitle,
|
|
]);
|
|
}
|
|
|
|
public function abdm_documents(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
$lastSync = $this->getLatestAbdmSyncSnapshot((string) ($abhaContext['abha_address'] ?? ''));
|
|
|
|
if (! $this->db->tableExists('abdm_hiu_documents')) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'count' => 0,
|
|
'items' => [],
|
|
'abha' => $abhaContext,
|
|
'last_sync' => $lastSync,
|
|
]);
|
|
}
|
|
|
|
$limit = (int) ($this->request->getGet('limit') ?? $_GET['limit'] ?? 100);
|
|
if ($limit <= 0 || $limit > 300) {
|
|
$limit = 100;
|
|
}
|
|
|
|
$q = trim((string) ($this->request->getGet('q') ?? $_GET['q'] ?? ''));
|
|
$filterConsentRequestId = trim((string) ($this->request->getGet('consent_request_id') ?? $_GET['consent_request_id'] ?? ''));
|
|
$includeSummary = ((int) ($this->request->getGet('include_summary') ?? $_GET['include_summary'] ?? 0)) === 1;
|
|
|
|
$docFields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
|
|
$selectCols = ['d.id', 'd.patient_id', 'd.abha_address', 'd.document_title', 'd.document_date', 'd.care_context_reference', 'd.practitioner_name', 'd.organization_name', 'd.bundle_type', 'd.created_at'];
|
|
if (in_array('consent_request_id', $docFields, true)) {
|
|
$selectCols[] = 'd.consent_request_id';
|
|
}
|
|
if (in_array('consent_artifact_id', $docFields, true)) {
|
|
$selectCols[] = 'd.consent_artifact_id';
|
|
}
|
|
if (in_array('consent_ref', $docFields, true)) {
|
|
$selectCols[] = 'd.consent_ref';
|
|
}
|
|
if ($includeSummary) {
|
|
$selectCols[] = 'd.summary_json';
|
|
}
|
|
|
|
$builder = $this->db->table('abdm_hiu_documents d')
|
|
->select(implode(', ', $selectCols))
|
|
->orderBy('d.document_date', 'DESC')
|
|
->orderBy('d.id', 'DESC')
|
|
->limit($limit);
|
|
|
|
$builder->groupStart()
|
|
->where('d.patient_id', $pno);
|
|
if ($abhaContext['abha_address'] !== '') {
|
|
$builder->orWhere('d.abha_address', $abhaContext['abha_address']);
|
|
}
|
|
$builder->groupEnd();
|
|
|
|
if ($filterConsentRequestId !== '') {
|
|
// A single M3 HIU consent request produces ONE consent artifact PER
|
|
// linked HIP facility (e.g. one for external entities like Trio Hospital,
|
|
// and another for the same entity like DevSoft Tech).
|
|
// Resolve all related umbrella and artifact IDs bidirectionally so
|
|
// documents from BOTH external and same-entity facilities are returned.
|
|
$sessionIds = $this->resolveAllRelatedConsentIds($filterConsentRequestId);
|
|
|
|
// ABDM M3 HIU compliance: check if this consent request is expired, revoked, or denied
|
|
$wfCheckQuery = $this->db->table('abdm_hiu_workflows')
|
|
->select(['id', 'workflow_state', 'request_json', 'expired_at'])
|
|
->groupStart()
|
|
->where('request_id', $filterConsentRequestId)
|
|
->orWhere('consent_id', $filterConsentRequestId)
|
|
->orWhereIn('abdm_consent_request_id', $sessionIds !== [] ? $sessionIds : [$filterConsentRequestId])
|
|
->orWhereIn('abdm_consent_artifact_id', $sessionIds !== [] ? $sessionIds : [$filterConsentRequestId]);
|
|
if (ctype_digit($filterConsentRequestId)) {
|
|
$wfCheckQuery->orWhere('id', (int) $filterConsentRequestId);
|
|
}
|
|
$wfCheck = $wfCheckQuery->groupEnd()
|
|
->orderBy('id', 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
$wfState = is_array($wfCheck) ? strtoupper(trim((string) ($wfCheck['workflow_state'] ?? ''))) : '';
|
|
$isFilterTerminal = in_array($wfState, ['EXPIRED', 'DENIED', 'REVOKED'], true);
|
|
if (! $isFilterTerminal && is_array($wfCheck)) {
|
|
$filterEraseAt = $this->extractDataEraseAtFromWorkflowRow($wfCheck);
|
|
if ($filterEraseAt !== '') {
|
|
$filterTs = strtotime($filterEraseAt);
|
|
if ($filterTs !== false && $filterTs <= time()) {
|
|
$isFilterTerminal = true;
|
|
$wfState = 'EXPIRED';
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($isFilterTerminal) {
|
|
// Purge cached records for this expired/revoked/denied consent as per ABDM dataEraseAt guidelines
|
|
$purgeRefs = array_unique(array_merge([$filterConsentRequestId], $sessionIds));
|
|
$this->purgeAbdmDocumentsByRefs($purgeRefs);
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'count' => 0,
|
|
'items' => [],
|
|
'expired' => 1,
|
|
'status' => $wfState !== '' ? $wfState : 'EXPIRED',
|
|
'error' => 'This consent request is ' . ($wfState !== '' ? $wfState : 'EXPIRED') . '. Health records are erased and no longer accessible as per ABDM guidelines.',
|
|
'abha' => $abhaContext,
|
|
'last_sync' => $lastSync,
|
|
]);
|
|
}
|
|
|
|
if ($sessionIds !== []) {
|
|
$builder->groupStart();
|
|
$hasClause = false;
|
|
if (in_array('consent_request_id', $docFields, true)) {
|
|
$builder->whereIn('d.consent_request_id', $sessionIds);
|
|
$hasClause = true;
|
|
}
|
|
if (in_array('consent_artifact_id', $docFields, true)) {
|
|
if ($hasClause) {
|
|
$builder->orWhereIn('d.consent_artifact_id', $sessionIds);
|
|
} else {
|
|
$builder->whereIn('d.consent_artifact_id', $sessionIds);
|
|
$hasClause = true;
|
|
}
|
|
}
|
|
if (in_array('consent_ref', $docFields, true)) {
|
|
if ($hasClause) {
|
|
$builder->orWhereIn('d.consent_ref', $sessionIds);
|
|
} else {
|
|
$builder->whereIn('d.consent_ref', $sessionIds);
|
|
$hasClause = true;
|
|
}
|
|
}
|
|
$builder->groupEnd();
|
|
}
|
|
}
|
|
|
|
if ($q !== '') {
|
|
$builder->groupStart()
|
|
->like('d.document_title', $q)
|
|
->orLike('d.care_context_reference', $q)
|
|
->orLike('d.practitioner_name', $q)
|
|
->orLike('d.organization_name', $q)
|
|
->groupEnd();
|
|
}
|
|
|
|
$rows = $builder->get()->getResultArray();
|
|
|
|
if ($includeSummary) {
|
|
foreach ($rows as &$rowRef) {
|
|
$summary = json_decode((string) ($rowRef['summary_json'] ?? ''), true);
|
|
if (! is_array($summary)) {
|
|
$summary = [];
|
|
}
|
|
// Attachments carry base64 image/PDF data — strip them from this
|
|
// list payload to keep it light; use abdm_document_detail for the
|
|
// full attachment content of a single document.
|
|
unset($summary['attachments']);
|
|
$rowRef['summary'] = $summary;
|
|
unset($rowRef['summary_json']);
|
|
}
|
|
unset($rowRef);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'count' => count($rows),
|
|
'items' => $rows,
|
|
'abha' => $abhaContext,
|
|
'last_sync' => $lastSync,
|
|
]);
|
|
}
|
|
|
|
|
|
public function abdm_document_detail(int $pno, int $docId)
|
|
{
|
|
if ($docId <= 0 || ! $this->db->tableExists('abdm_hiu_documents')) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Document not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
|
|
$builder = $this->db->table('abdm_hiu_documents d')
|
|
->select('*')
|
|
->where('d.id', $docId)
|
|
->groupStart()
|
|
->where('d.patient_id', $pno);
|
|
if ($abhaContext['abha_address'] !== '') {
|
|
$builder->orWhere('d.abha_address', $abhaContext['abha_address']);
|
|
}
|
|
$builder->groupEnd();
|
|
|
|
$row = $builder->get(1)->getRowArray();
|
|
if (! is_array($row) || empty($row)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Document not found for patient',
|
|
]);
|
|
}
|
|
|
|
$document = (new \App\Libraries\Abdm\M3HiuDocumentRepository())->getDocument($docId);
|
|
$row['summary'] = is_array($document['summary'] ?? null)
|
|
? $document['summary']
|
|
: (json_decode((string) ($row['summary_json'] ?? ''), true) ?: []);
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'item' => $row,
|
|
]);
|
|
}
|
|
|
|
public function abdm_records_pdf(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setBody('patient_master table not found');
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setBody('Patient not found');
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
$patientAbha = trim((string) ($abhaContext['abha_address'] ?? ''));
|
|
|
|
if (! $this->db->tableExists('abdm_hiu_documents')) {
|
|
return $this->response->setStatusCode(404)->setBody('No ABDM documents table found');
|
|
}
|
|
|
|
ini_set('pcre.backtrack_limit', '10000000');
|
|
ini_set('memory_limit', '512M');
|
|
|
|
$docId = (int) ($this->request->getGet('doc_id') ?? 0);
|
|
$consentRequestId = trim((string) ($this->request->getGet('consent_request_id') ?? ''));
|
|
|
|
$selectCols = ['d.id', 'd.patient_id', 'd.abha_address', 'd.document_title', 'd.document_date', 'd.care_context_reference', 'd.practitioner_name', 'd.organization_name', 'd.bundle_type', 'd.created_at', 'd.summary_json'];
|
|
|
|
$builder = $this->db->table('abdm_hiu_documents d')
|
|
->select(implode(', ', $selectCols))
|
|
->orderBy('d.document_date', 'DESC')
|
|
->orderBy('d.id', 'DESC');
|
|
|
|
$builder->groupStart()
|
|
->where('d.patient_id', $pno);
|
|
if ($patientAbha !== '') {
|
|
$builder->orWhere('d.abha_address', $patientAbha);
|
|
}
|
|
$builder->groupEnd();
|
|
|
|
if ($docId > 0) {
|
|
$builder->where('d.id', $docId)->limit(1);
|
|
} elseif ($consentRequestId !== '') {
|
|
$docFields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
|
|
$sessionIds = $this->resolveAllRelatedConsentIds($consentRequestId);
|
|
|
|
// ABDM M3 HIU compliance: check if consent has expired, been denied, or revoked
|
|
$wfCheckQuery = $this->db->table('abdm_hiu_workflows')
|
|
->select(['id', 'workflow_state', 'request_json', 'expired_at'])
|
|
->groupStart()
|
|
->where('request_id', $consentRequestId)
|
|
->orWhere('consent_id', $consentRequestId)
|
|
->orWhereIn('abdm_consent_request_id', $sessionIds !== [] ? $sessionIds : [$consentRequestId])
|
|
->orWhereIn('abdm_consent_artifact_id', $sessionIds !== [] ? $sessionIds : [$consentRequestId]);
|
|
if (ctype_digit($consentRequestId)) {
|
|
$wfCheckQuery->orWhere('id', (int) $consentRequestId);
|
|
}
|
|
$wfCheck = $wfCheckQuery->groupEnd()
|
|
->orderBy('id', 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
if (is_array($wfCheck)) {
|
|
$eraseAt = $this->extractDataEraseAtFromWorkflowRow($wfCheck);
|
|
$wfState = strtoupper(trim((string) ($wfCheck['workflow_state'] ?? '')));
|
|
if (in_array($wfState, ['EXPIRED', 'DENIED', 'REVOKED'], true) || ($eraseAt !== '' && strtotime($eraseAt) <= time())) {
|
|
$this->purgeAbdmDocumentsByRefs(array_merge([$consentRequestId], $sessionIds));
|
|
return $this->response->setStatusCode(403)->setBody('This consent request is ' . ($wfState !== '' ? $wfState : 'EXPIRED') . '. Health records are erased and cannot be viewed or printed as per ABDM guidelines.');
|
|
}
|
|
}
|
|
|
|
if ($sessionIds !== []) {
|
|
$builder->groupStart();
|
|
$hasClause = false;
|
|
if (in_array('consent_request_id', $docFields, true)) {
|
|
$builder->whereIn('d.consent_request_id', $sessionIds);
|
|
$hasClause = true;
|
|
}
|
|
if (in_array('consent_artifact_id', $docFields, true)) {
|
|
if ($hasClause) {
|
|
$builder->orWhereIn('d.consent_artifact_id', $sessionIds);
|
|
} else {
|
|
$builder->whereIn('d.consent_artifact_id', $sessionIds);
|
|
$hasClause = true;
|
|
}
|
|
}
|
|
if (in_array('consent_ref', $docFields, true)) {
|
|
if ($hasClause) {
|
|
$builder->orWhereIn('d.consent_ref', $sessionIds);
|
|
} else {
|
|
$builder->whereIn('d.consent_ref', $sessionIds);
|
|
$hasClause = true;
|
|
}
|
|
}
|
|
$builder->groupEnd();
|
|
}
|
|
$builder->limit(30);
|
|
} else {
|
|
return $this->response->setStatusCode(400)->setBody('A valid consent request ID is required to print health records.');
|
|
}
|
|
|
|
$rows = $builder->get()->getResultArray();
|
|
|
|
foreach ($rows as &$r) {
|
|
$summary = json_decode((string) ($r['summary_json'] ?? ''), true);
|
|
if (! is_array($summary)) {
|
|
$summary = [];
|
|
}
|
|
if (! empty($summary['attachments']) && is_array($summary['attachments'])) {
|
|
foreach ($summary['attachments'] as &$att) {
|
|
unset($att['data']);
|
|
}
|
|
unset($att);
|
|
}
|
|
$r['summary'] = $summary;
|
|
unset($r['summary_json']);
|
|
}
|
|
unset($r);
|
|
|
|
$hospitalName = defined('H_Name') ? (string) constant('H_Name') : 'Hospital';
|
|
$hospitalAddress1 = defined('H_address_1') ? (string) constant('H_address_1') : '';
|
|
$hospitalAddress2 = defined('H_address_2') ? (string) constant('H_address_2') : '';
|
|
$hospitalPhone = defined('H_phone_No') ? (string) constant('H_phone_No') : '';
|
|
$hospitalEmail = defined('H_Email') ? (string) constant('H_Email') : '';
|
|
$hospitalLogoName = defined('H_logo') ? trim((string) constant('H_logo')) : '';
|
|
|
|
$logoPathCandidates = [];
|
|
if ($hospitalLogoName !== '') {
|
|
$logoPathCandidates[] = FCPATH . 'assets/images/' . $hospitalLogoName;
|
|
$logoPathCandidates[] = FCPATH . 'assets/img/' . $hospitalLogoName;
|
|
}
|
|
$logoPathCandidates[] = FCPATH . 'assets/img/logo.png';
|
|
$logoPathCandidates[] = FCPATH . 'assets/images/logo.png';
|
|
|
|
$logoSrc = '';
|
|
foreach ($logoPathCandidates as $candidate) {
|
|
if (is_file($candidate)) {
|
|
$logoSrc = str_replace('\\', '/', $candidate);
|
|
break;
|
|
}
|
|
}
|
|
|
|
$title = trim((string) ($patientRow['title'] ?? ''));
|
|
$fname = trim((string) ($patientRow['p_fname'] ?? ''));
|
|
$patientName = trim($title . ' ' . $fname);
|
|
if ($patientName === '') {
|
|
$patientName = 'Patient #' . $pno;
|
|
}
|
|
$genderVal = (int) ($patientRow['gender'] ?? 0);
|
|
$genderStr = $genderVal === 1 ? 'Male' : ($genderVal === 2 ? 'Female' : ($genderVal === 3 ? 'Other' : '-'));
|
|
$ageStr = ! empty($patientRow['age']) ? ($patientRow['age'] . ' Year') : '';
|
|
$ageGender = trim($ageStr . ($ageStr !== '' && $genderStr !== '-' ? ' / ' : '') . ($genderStr !== '-' ? $genderStr : ''));
|
|
|
|
$data = [
|
|
'title' => 'ABDM Health Records - ' . $patientName,
|
|
'patient' => $patientRow,
|
|
'patientId' => (int) ($patientRow['id'] ?? $pno),
|
|
'patientCode' => (string) ($patientRow['p_code'] ?? ('#' . $pno)),
|
|
'patientName' => $patientName,
|
|
'ageGender' => $ageGender,
|
|
'mobile' => (string) ($patientRow['mphone1'] ?? ''),
|
|
'abhaAddress' => $patientAbha,
|
|
'abhaNumber' => (string) ($patientRow['abha_id'] ?? ''),
|
|
'consentRequestId' => $consentRequestId,
|
|
'documents' => $rows,
|
|
'hospitalName' => $hospitalName,
|
|
'hospitalAddress' => trim($hospitalAddress1 . ($hospitalAddress2 !== '' ? ', ' . $hospitalAddress2 : '')),
|
|
'hospitalPhone' => $hospitalPhone,
|
|
'hospitalEmail' => $hospitalEmail,
|
|
'logoSrc' => $logoSrc,
|
|
'printedOn' => date('d-m-Y h:i A'),
|
|
];
|
|
|
|
$html = view('billing/abdm_records_pdf_v', $data);
|
|
|
|
$mpdfTempDir = WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'mpdf';
|
|
if (! is_dir($mpdfTempDir)) {
|
|
mkdir($mpdfTempDir, 0755, true);
|
|
}
|
|
|
|
$mpdf = new \Mpdf\Mpdf([
|
|
'mode' => 'utf-8',
|
|
'format' => 'A4',
|
|
'orientation' => 'P',
|
|
'margin_left' => 10,
|
|
'margin_right' => 10,
|
|
'margin_top' => 10,
|
|
'margin_bottom' => 12,
|
|
'default_font' => 'freeserif',
|
|
'tempDir' => $mpdfTempDir,
|
|
]);
|
|
$mpdf->autoScriptToLang = true;
|
|
$mpdf->autoLangToFont = true;
|
|
|
|
$footerHtml = '<table style="width: 100%; border-top: 1px solid #cbd5e1; font-size: 7.5pt; color: #64748b; padding-top: 3px;">'
|
|
. '<tr>'
|
|
. '<td style="text-align: left; width: 75%;">Ayushman Bharat Digital Mission (ABDM) HIU • Confidential • For Clinical Evaluation Only</td>'
|
|
. '<td style="text-align: right; width: 25%;">Page {PAGENO} of {nbpg}</td>'
|
|
. '</tr>'
|
|
. '</table>';
|
|
$mpdf->SetHTMLFooter($footerHtml, 'O');
|
|
$mpdf->SetHTMLFooter($footerHtml, 'E');
|
|
|
|
$mpdf->SetTitle('ABDM Health Records - ' . $patientName);
|
|
$mpdf->WriteHTML($html);
|
|
|
|
$safeName = preg_replace('/[^A-Za-z0-9_-]/', '_', $patientName);
|
|
$fileName = 'ABDM_Records_' . $safeName . '_' . date('Ymd_His') . '.pdf';
|
|
|
|
return $this->response
|
|
->setHeader('Content-Type', 'application/pdf')
|
|
->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"')
|
|
->setBody($mpdf->Output($fileName, 'S'));
|
|
}
|
|
|
|
public function abdm_content_request(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
if ($abhaContext['abha_address'] === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA Address not available for this patient.',
|
|
'abha' => $abhaContext,
|
|
]);
|
|
}
|
|
|
|
if ((int) ($abhaContext['is_verified'] ?? 0) !== 1) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA is not marked as verified in HMS. Please verify ABHA first, then request content.',
|
|
'abha' => $abhaContext,
|
|
]);
|
|
}
|
|
|
|
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
|
|
$payload = [
|
|
'patient_id' => $pno,
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
'hiTypes' => ['OPConsultation', 'Prescription', 'DiagnosticReport', 'HealthDocumentRecord'],
|
|
];
|
|
|
|
try {
|
|
$result = $service->runOperation('consent_request', $payload);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Unable to start content request: ' . $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
$ok = (int) ($result['ok'] ?? 0) === 1;
|
|
$httpCode = (int) ($result['http_code'] ?? ($ok ? 200 : 422));
|
|
if ($httpCode < 100 || $httpCode > 599) {
|
|
$httpCode = $ok ? 200 : 422;
|
|
}
|
|
|
|
if (! $ok) {
|
|
return $this->response->setStatusCode($httpCode)->setJSON([
|
|
'ok' => 0,
|
|
'error' => (string) ($result['error_text'] ?? 'Content request failed.'),
|
|
'data' => $result,
|
|
]);
|
|
}
|
|
|
|
return $this->response->setStatusCode($httpCode)->setJSON([
|
|
'ok' => 1,
|
|
'message' => 'Consent/content request created successfully.',
|
|
'workflow_state' => 'REQUESTED',
|
|
'request_id' => (string) ($result['request_id'] ?? ''),
|
|
'data' => $result,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Manual consent request form submission — lets the user pick specific Health
|
|
* Information Types and an editable validity date range instead of always
|
|
* using the hardcoded defaults from M3HiuWorkflowService::buildDefaultConsent().
|
|
* Returns the same shape as abdm_content_request() so the existing JS auto-flow
|
|
* polling (runAutoFlowStep) can pick up the request_id and continue tracking
|
|
* consent status / data fetch to completion.
|
|
*/
|
|
public function abdm_content_request_custom(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
if ($abhaContext['abha_address'] === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA Address not available for this patient.',
|
|
'abha' => $abhaContext,
|
|
]);
|
|
}
|
|
|
|
if ((int) ($abhaContext['is_verified'] ?? 0) !== 1) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA is not marked as verified in HMS. Please verify ABHA first, then request content.',
|
|
'abha' => $abhaContext,
|
|
]);
|
|
}
|
|
|
|
$hiTypesInput = $this->request->getPost('hi_types');
|
|
if (! is_array($hiTypesInput)) {
|
|
$decoded = json_decode((string) $hiTypesInput, true);
|
|
$hiTypesInput = is_array($decoded) ? $decoded : [];
|
|
}
|
|
$hiTypes = $this->normalizeHiTypesList($hiTypesInput);
|
|
if ($hiTypes === []) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Select at least one Health Information Type.',
|
|
]);
|
|
}
|
|
|
|
$dateFromInput = trim((string) $this->request->getPost('date_from'));
|
|
$dateToInput = trim((string) $this->request->getPost('date_to'));
|
|
$eraseDateInput = trim((string) $this->request->getPost('erase_date'));
|
|
|
|
// Purpose is a fixed ABDM vocabulary, selected from a dropdown (not free
|
|
// text) so it can never be mistyped into an invalid purpose code.
|
|
$purposeTextByCode = [
|
|
'CAREMGT' => 'Care Management',
|
|
'BTG' => 'Break The Glass',
|
|
'PUBHLTH' => 'Public Health',
|
|
'HPAYMT' => 'Healthcare Payment',
|
|
'DSRCH' => 'Disease Specific Healthcare Research',
|
|
'PATRQT' => 'Self Requested',
|
|
];
|
|
$purposeCode = strtoupper(trim((string) $this->request->getPost('purpose_code')));
|
|
if (! array_key_exists($purposeCode, $purposeTextByCode)) {
|
|
$purposeCode = 'CAREMGT';
|
|
}
|
|
|
|
$istTz = new \DateTimeZone('Asia/Kolkata');
|
|
$utcTz = new \DateTimeZone('UTC');
|
|
$nowIst = new \DateTimeImmutable('now', $istTz);
|
|
|
|
try {
|
|
if ($dateFromInput !== '') {
|
|
$fromIst = new \DateTimeImmutable($dateFromInput, $istTz);
|
|
if (strpos($dateFromInput, 'T') === false && strpos($dateFromInput, ' ') === false) {
|
|
$fromIst = $fromIst->setTime(0, 0, 0);
|
|
}
|
|
} else {
|
|
$fromIst = $nowIst->modify('-365 days')->setTime(0, 0, 0);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Invalid "Date From" date/time.',
|
|
]);
|
|
}
|
|
try {
|
|
if ($dateToInput !== '') {
|
|
$toIst = new \DateTimeImmutable($dateToInput, $istTz);
|
|
} else {
|
|
$toIst = $nowIst;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Invalid "Date To" date/time.',
|
|
]);
|
|
}
|
|
if ($fromIst->getTimestamp() >= $toIst->getTimestamp()) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => '"Date From" must be earlier than "Date To".',
|
|
]);
|
|
}
|
|
|
|
try {
|
|
if ($eraseDateInput !== '') {
|
|
$eraseIst = new \DateTimeImmutable($eraseDateInput, $istTz);
|
|
if (strpos($eraseDateInput, 'T') === false && strpos($eraseDateInput, ' ') === false) {
|
|
$eraseIst = $eraseIst->setTime(23, 59, 59);
|
|
}
|
|
} else {
|
|
$eraseIst = $nowIst->modify('+1 year')->setTime(23, 59, 59);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Invalid "Expiry Date" date/time.',
|
|
]);
|
|
}
|
|
if ($eraseIst->getTimestamp() < $toIst->getTimestamp()) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => '"Expiry Date" must be on or after "Date To".',
|
|
]);
|
|
}
|
|
|
|
$safeNowIst = $nowIst->modify('-120 seconds');
|
|
if ($toIst->getTimestamp() > $safeNowIst->getTimestamp()) {
|
|
$toIst = $safeNowIst;
|
|
}
|
|
|
|
$fromUtc = $fromIst->setTimezone($utcTz)->format('Y-m-d\TH:i:s.000\Z');
|
|
$toUtc = $toIst->setTimezone($utcTz)->format('Y-m-d\TH:i:s.000\Z');
|
|
$eraseAtUtc = $eraseIst->setTimezone($utcTz)->format('Y-m-d\TH:i:s.000\Z');
|
|
|
|
$hipId = trim((string) ($this->request->getPost('hip_id') ?? ''));
|
|
|
|
$consent = [
|
|
'purpose' => [
|
|
'code' => $purposeCode,
|
|
'text' => $purposeTextByCode[$purposeCode],
|
|
'refUri' => 'https://abdm.gov.in',
|
|
],
|
|
'patient' => [
|
|
'id' => $abhaContext['abha_address'],
|
|
],
|
|
'hiTypes' => $hiTypes,
|
|
'permission' => [
|
|
'accessMode' => 'VIEW',
|
|
'dateRange' => [
|
|
'from' => $fromUtc,
|
|
'to' => $toUtc,
|
|
],
|
|
'dataEraseAt' => $eraseAtUtc,
|
|
'frequency' => [
|
|
'unit' => 'HOUR',
|
|
'value' => 1,
|
|
'repeats' => 0,
|
|
],
|
|
],
|
|
];
|
|
if ($hipId !== '') {
|
|
$consent['hip'] = ['id' => $hipId];
|
|
}
|
|
|
|
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
|
|
$payload = [
|
|
'patient_id' => $pno,
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
'consent' => $consent,
|
|
];
|
|
if ($hipId !== '') {
|
|
$payload['hip_id'] = $hipId;
|
|
}
|
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
session_write_close();
|
|
}
|
|
|
|
try {
|
|
$result = $service->runOperation('consent_request', $payload);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Unable to start content request: ' . $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
$ok = (int) ($result['ok'] ?? 0) === 1;
|
|
$httpCode = (int) ($result['http_code'] ?? ($ok ? 200 : 422));
|
|
if ($httpCode < 100 || $httpCode > 599) {
|
|
$httpCode = $ok ? 200 : 422;
|
|
}
|
|
|
|
if (! $ok) {
|
|
return $this->response->setStatusCode($httpCode)->setJSON([
|
|
'ok' => 0,
|
|
'error' => (string) ($result['error_text'] ?? 'Content request failed.'),
|
|
'data' => $result,
|
|
]);
|
|
}
|
|
|
|
return $this->response->setStatusCode($httpCode)->setJSON([
|
|
'ok' => 1,
|
|
'message' => 'Consent/content request created successfully.',
|
|
'workflow_state' => 'REQUESTED',
|
|
'request_id' => (string) ($result['request_id'] ?? ''),
|
|
'data' => $result,
|
|
]);
|
|
}
|
|
|
|
public function abdm_content_auto_flow(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
if ($abhaContext['abha_address'] === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA Address not available for this patient.',
|
|
'abha' => $abhaContext,
|
|
]);
|
|
}
|
|
|
|
if ((int) ($abhaContext['is_verified'] ?? 0) !== 1) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA is not marked as verified in HMS. Please verify ABHA first, then request content.',
|
|
'abha' => $abhaContext,
|
|
]);
|
|
}
|
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
session_write_close();
|
|
}
|
|
|
|
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
|
|
$flowRefId = trim((string) (
|
|
$this->request->getGet('request_id')
|
|
?? $this->request->getPost('request_id')
|
|
?? ''
|
|
));
|
|
$consentRequestRef = '';
|
|
$consentArtifactRef = '';
|
|
|
|
$consentResult = null;
|
|
$reconcileResult = null;
|
|
$fetchResult = null;
|
|
$lastSync = $this->getLatestAbdmSyncSnapshot((string) ($abhaContext['abha_address'] ?? ''));
|
|
$resumedFromLastSync = false;
|
|
|
|
if ($flowRefId === '') {
|
|
$lastRequestId = trim((string) ($lastSync['request_id'] ?? ''));
|
|
$lastPhase = strtoupper(trim((string) ($lastSync['phase'] ?? '')));
|
|
$lastSyncStale = (bool) ($lastSync['restart_required'] ?? false);
|
|
// Do not resume a stale pending request (older than 1 hour with no bridge
|
|
// callback) — fall through so a fresh consent_request is created instead.
|
|
if ($lastRequestId !== '' && ! $lastSyncStale && in_array($lastPhase, ['REQUESTED', 'PENDING', 'GRANTED'], true)) {
|
|
$flowRefId = $lastRequestId;
|
|
$consentRequestRef = trim((string) ($lastSync['consent_request_id'] ?? ''));
|
|
$consentArtifactRef = trim((string) ($lastSync['consent_id'] ?? ''));
|
|
$resumedFromLastSync = true;
|
|
$consentResult = [
|
|
'ok' => 1,
|
|
'http_code' => 200,
|
|
'request_id' => $flowRefId,
|
|
'workflow_state' => $lastPhase,
|
|
'reused_existing_request' => 1,
|
|
'message' => 'Resuming previous ABDM consent flow from saved status.',
|
|
];
|
|
}
|
|
}
|
|
|
|
if ($flowRefId === '') {
|
|
$consentPayload = [
|
|
'patient_id' => $pno,
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
'hiTypes' => ['OPConsultation', 'Prescription', 'DiagnosticReport', 'HealthDocumentRecord'],
|
|
];
|
|
|
|
try {
|
|
$consentResult = $service->runOperation('consent_request', $consentPayload);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Unable to start consent request: ' . $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
if ((int) ($consentResult['ok'] ?? 0) !== 1) {
|
|
$errorText = trim((string) ($consentResult['error_text'] ?? 'Consent request failed.'));
|
|
$isDuplicateConsent = stripos($errorText, 'ABDM-1070') !== false
|
|
|| stripos($errorText, 'Duplicate consent request') !== false;
|
|
|
|
if ($isDuplicateConsent) {
|
|
$existingRequestId = $this->findLatestAbdmGatewayRequestId($abhaContext['abha_address']);
|
|
if ($existingRequestId !== '') {
|
|
$consentResult['ok'] = 1;
|
|
$consentResult['http_code'] = 200;
|
|
$consentResult['request_id'] = $existingRequestId;
|
|
$consentResult['workflow_state'] = 'REQUESTED';
|
|
$consentResult['reused_existing_request'] = 1;
|
|
$consentResult['message'] = 'Duplicate consent detected. Reusing latest request for reconciliation.';
|
|
}
|
|
}
|
|
|
|
if ((int) ($consentResult['ok'] ?? 0) !== 1) {
|
|
$httpCode = (int) ($consentResult['http_code'] ?? 422);
|
|
if ($httpCode < 100 || $httpCode > 599) {
|
|
$httpCode = 422;
|
|
}
|
|
|
|
return $this->response->setStatusCode($httpCode)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => 'REQUEST_FAILED',
|
|
'error' => (string) ($consentResult['error_text'] ?? 'Consent request failed.'),
|
|
'request_id' => (string) ($consentResult['request_id'] ?? ''),
|
|
'data' => $consentResult,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$flowRefId = trim((string) (
|
|
$consentResult['consent_request_id']
|
|
?? $consentResult['abdm_consent_request_id']
|
|
?? $consentResult['consent_id']
|
|
?? $consentResult['request_id']
|
|
?? ''
|
|
));
|
|
$consentRequestRef = trim((string) (
|
|
$consentResult['consent_request_id']
|
|
?? $consentResult['abdm_consent_request_id']
|
|
?? ''
|
|
));
|
|
$consentArtifactRef = trim((string) (
|
|
$consentResult['consent_id']
|
|
?? $consentResult['abdm_consent_artifact_id']
|
|
?? ''
|
|
));
|
|
if ($consentArtifactRef === '' && $consentRequestRef !== '' && preg_match('/^REQ-/i', $consentRequestRef) !== 1) {
|
|
$consentArtifactRef = $consentRequestRef;
|
|
}
|
|
}
|
|
|
|
if ($flowRefId === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => 'REQUEST_MISSING',
|
|
'error' => 'Unable to determine consent reference for auto flow.',
|
|
]);
|
|
}
|
|
|
|
if ($consentRequestRef === '' && preg_match('/^REQ-HIU-/i', $flowRefId) === 1) {
|
|
$resolvedRequestId = $this->findLatestAbdmGatewayRequestId($abhaContext['abha_address']);
|
|
if ($resolvedRequestId !== '') {
|
|
$flowRefId = $resolvedRequestId;
|
|
}
|
|
}
|
|
|
|
// If we already have a granted snapshot with a concrete consent reference,
|
|
// check if records exist or let background worker fetch them without blocking the browser.
|
|
if ($resumedFromLastSync && strtoupper(trim((string) ($lastSync['phase'] ?? ''))) === 'GRANTED') {
|
|
if ($consentArtifactRef === '' && $consentRequestRef === '') {
|
|
$consentRequestRef = trim((string) ($lastSync['consent_request_id'] ?? ''));
|
|
$consentArtifactRef = trim((string) ($lastSync['consent_id'] ?? ''));
|
|
}
|
|
|
|
$docCount = $this->countAbdmDocumentsForPatient($pno, $consentRequestRef);
|
|
if ($docCount > 0) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'COMPLETED',
|
|
'poll_again' => 0,
|
|
'request_id' => $flowRefId,
|
|
'message' => 'Resumed previous granted consent flow and ' . $docCount . ' record(s) are available.',
|
|
'data' => [
|
|
'consent_request' => $consentResult,
|
|
'consent_reconcile' => $reconcileResult,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// Records not yet stored locally; trigger background HIU poll so browser doesn't block
|
|
$this->triggerBackgroundHiuPoll();
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'GRANTED',
|
|
'poll_again' => 1,
|
|
'request_id' => $flowRefId,
|
|
'message' => 'Consent is granted. Records are being fetched in the background...',
|
|
'data' => [
|
|
'consent_request' => $consentResult,
|
|
'consent_reconcile' => $reconcileResult,
|
|
],
|
|
]);
|
|
}
|
|
|
|
$reconcilePayload = [
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
];
|
|
if ($flowRefId !== '') {
|
|
$reconcilePayload['request_id'] = $flowRefId;
|
|
}
|
|
if ($consentRequestRef !== '') {
|
|
$reconcilePayload['consentRequestId'] = $consentRequestRef;
|
|
}
|
|
|
|
try {
|
|
$reconcileResult = $service->runOperation('consent_reconcile', $reconcilePayload);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => 'STATUS_FAILED',
|
|
'error' => 'Unable to reconcile consent status: ' . $e->getMessage(),
|
|
'request_id' => $flowRefId,
|
|
]);
|
|
}
|
|
|
|
if ((int) ($reconcileResult['ok'] ?? 0) !== 1) {
|
|
$errorText = trim((string) ($reconcileResult['error_text'] ?? 'Consent status check failed.'));
|
|
$httpCode = (int) ($reconcileResult['http_code'] ?? 422);
|
|
// Bridge uses several different wordings for "this reference doesn't
|
|
// resolve to a session yet" depending on which lookup key was sent
|
|
// (request_id vs consentRequestId vs consentId) -- e.g. "Consent
|
|
// record not found", "NOT_FOUND", or "No HIU sessions found matching
|
|
// the criteria". Treat all of these as non-fatal/still-pending
|
|
// (bridge just hasn't indexed the session under that key yet) rather
|
|
// than surfacing a hard "Auto flow failed" error to the user, which
|
|
// was happening repeatedly because only the first two wordings were
|
|
// recognized. Also accept 400 alongside 404 since the bridge doesn't
|
|
// consistently use 404 for this class of response.
|
|
$isNonFatalPending = $this->isPendingBridgeSessionError($errorText, $httpCode);
|
|
|
|
if ($isNonFatalPending) {
|
|
$fallbackLookup = [
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
];
|
|
if ($consentRequestRef !== '') {
|
|
$fallbackLookup['consentRequestId'] = $consentRequestRef;
|
|
}
|
|
if ($consentArtifactRef !== '') {
|
|
$fallbackLookup['consentId'] = $consentArtifactRef;
|
|
}
|
|
if ($flowRefId !== '') {
|
|
$fallbackLookup['request_id'] = $flowRefId;
|
|
}
|
|
|
|
if ($consentRequestRef !== '' || $consentArtifactRef !== '') {
|
|
try {
|
|
$fallbackReconcile = $service->runOperation('consent_reconcile', $fallbackLookup);
|
|
} catch (\Throwable $e) {
|
|
$fallbackReconcile = [
|
|
'ok' => 0,
|
|
'http_code' => 500,
|
|
'error_text' => 'Unable to reconcile consent status using fallback reference: ' . $e->getMessage(),
|
|
];
|
|
}
|
|
|
|
if ((int) ($fallbackReconcile['ok'] ?? 0) === 1) {
|
|
$reconcileResult = $fallbackReconcile;
|
|
$state = $this->deriveAutoFlowState($reconcileResult);
|
|
if ($state === 'GRANTED') {
|
|
$docCount = $this->countAbdmDocumentsForPatient($pno, $consentRequestRef);
|
|
if ($docCount > 0) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'COMPLETED',
|
|
'poll_again' => 0,
|
|
'request_id' => $flowRefId,
|
|
'message' => 'Consent resolved and ' . $docCount . ' record(s) are available.',
|
|
'data' => [
|
|
'consent_request' => $consentResult,
|
|
'consent_reconcile' => $reconcileResult,
|
|
],
|
|
]);
|
|
}
|
|
|
|
$this->triggerBackgroundHiuPoll();
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'GRANTED',
|
|
'poll_again' => 1,
|
|
'request_id' => $flowRefId,
|
|
'message' => 'Consent granted. Records are being fetched in the background...',
|
|
'data' => [
|
|
'consent_request' => $consentResult,
|
|
'consent_reconcile' => $reconcileResult,
|
|
],
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'REQUESTED',
|
|
// Keep tracking the same first request; bridge may still be processing callbacks.
|
|
'poll_again' => 1,
|
|
'request_id' => $flowRefId,
|
|
'message' => 'Consent request is still being processed. Waiting for bridge callback update.',
|
|
'data' => [
|
|
'consent_request' => $consentResult,
|
|
'consent_reconcile' => $reconcileResult,
|
|
],
|
|
]);
|
|
}
|
|
|
|
if ($httpCode < 100 || $httpCode > 599) {
|
|
$httpCode = 422;
|
|
}
|
|
|
|
return $this->response->setStatusCode($httpCode)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => 'STATUS_FAILED',
|
|
'error' => (string) ($reconcileResult['error_text'] ?? 'Consent status check failed.'),
|
|
'request_id' => $flowRefId,
|
|
'data' => [
|
|
'consent_request' => $consentResult,
|
|
'consent_reconcile' => $reconcileResult,
|
|
],
|
|
]);
|
|
}
|
|
|
|
$state = $this->deriveAutoFlowState($reconcileResult);
|
|
$pollAgain = in_array($state, ['REQUESTED', 'PENDING'], true);
|
|
|
|
if ($state === 'GRANTED') {
|
|
$docCount = $this->countAbdmDocumentsForPatient($pno, $consentRequestRef);
|
|
if ($docCount > 0) {
|
|
$state = 'COMPLETED';
|
|
$pollAgain = false;
|
|
$message = 'Data fetch completed and ' . $docCount . ' document(s) are now available.';
|
|
} else {
|
|
// Consent is granted in PHR, but documents are not yet downloaded/decrypted.
|
|
// Trigger background HIU poll worker so the browser does NOT hang on synchronous data_fetch.
|
|
$this->triggerBackgroundHiuPoll();
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'GRANTED',
|
|
'poll_again' => 1,
|
|
'request_id' => $flowRefId,
|
|
'message' => 'Consent granted. Health records are being fetched in the background...',
|
|
'data' => [
|
|
'consent_request' => $consentResult,
|
|
'consent_reconcile' => $reconcileResult,
|
|
],
|
|
]);
|
|
}
|
|
}
|
|
|
|
$message = match ($state) {
|
|
'COMPLETED' => 'Data fetch completed and documents should now be available.',
|
|
'GRANTED' => 'Consent granted. Proceeding to fetch data.',
|
|
'DENIED' => 'Consent denied/revoked/expired by patient.',
|
|
default => 'Consent request created. Waiting for patient approval.',
|
|
};
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => $state,
|
|
'poll_again' => $pollAgain ? 1 : 0,
|
|
'request_id' => $flowRefId,
|
|
'message' => $message,
|
|
'data' => [
|
|
'consent_request' => $consentResult,
|
|
'consent_reconcile' => $reconcileResult,
|
|
'data_fetch' => $fetchResult,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Fetch-only action for a patient whose consent is already GRANTED.
|
|
* Unlike abdm_content_auto_flow(), this does NOT create a new consent
|
|
* request and does NOT re-run consent reconcile polling — it directly
|
|
* pulls health data using the already-saved consent reference. This
|
|
* avoids spamming the bridge with duplicate consent_request/reconcile
|
|
* calls just to re-fetch already-granted records.
|
|
*/
|
|
public function abdm_content_fetch_only(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
if ($abhaContext['abha_address'] === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA Address not available for this patient.',
|
|
]);
|
|
}
|
|
|
|
// Optional overrides let the "Fetch Records" action in the Consent Request
|
|
// History list target a SPECIFIC past session's consent reference, instead
|
|
// of always re-pulling using the globally-latest granted consent.
|
|
$overrideConsentId = trim((string) (
|
|
$this->request->getGet('consent_id')
|
|
?? $this->request->getPost('consent_id')
|
|
?? ''
|
|
));
|
|
$overrideConsentRequestId = trim((string) (
|
|
$this->request->getGet('consent_request_id')
|
|
?? $this->request->getPost('consent_request_id')
|
|
?? ''
|
|
));
|
|
|
|
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
|
|
|
|
if ($overrideConsentId !== '' || $overrideConsentRequestId !== '') {
|
|
$consentArtifactRef = $overrideConsentId;
|
|
$consentRequestRef = $overrideConsentRequestId;
|
|
$lookupTarget = $consentRequestRef !== '' ? $consentRequestRef : $consentArtifactRef;
|
|
|
|
// Actively reconcile live status with the bridge before fetching records
|
|
$reconcilePayload = [
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
'request_id' => $lookupTarget,
|
|
'abdm_consent_request_id' => $consentRequestRef,
|
|
'abdm_consent_artifact_id'=> $consentArtifactRef,
|
|
'consent_id' => $consentArtifactRef !== '' ? $consentArtifactRef : $lookupTarget,
|
|
];
|
|
|
|
$liveStatus = '';
|
|
try {
|
|
$reconcile = $service->runOperation('consent_reconcile', $reconcilePayload);
|
|
if (is_array($reconcile) && (int) ($reconcile['ok'] ?? 0) === 1) {
|
|
$liveStatus = strtoupper(trim((string) (
|
|
$reconcile['workflow_state']
|
|
?? $reconcile['status']
|
|
?? $reconcile['consent_status']
|
|
?? $reconcile['consent']['status']
|
|
?? $reconcile['consentDetail']['status']
|
|
?? ''
|
|
)));
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('warning', '[abdm_content_fetch_only] consent_reconcile pre-fetch check warning: ' . $e->getMessage());
|
|
$reconcile = [];
|
|
}
|
|
|
|
// Check local workflow anchor row
|
|
$checkWfQuery = $this->db->table('abdm_hiu_workflows')
|
|
->select(['id', 'workflow_state', 'request_json', 'expired_at'])
|
|
->groupStart()
|
|
->where('request_id', $lookupTarget)
|
|
->orWhere('consent_id', $lookupTarget)
|
|
->orWhere('abdm_consent_request_id', $lookupTarget)
|
|
->orWhere('abdm_consent_artifact_id', $lookupTarget);
|
|
if (ctype_digit($lookupTarget)) {
|
|
$checkWfQuery->orWhere('id', (int) $lookupTarget);
|
|
}
|
|
$checkWf = $checkWfQuery->groupEnd()
|
|
->orderBy('id', 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
$anchorRowId = is_array($checkWf) ? (int) ($checkWf['id'] ?? 0) : 0;
|
|
if ($anchorRowId > 0 && ! empty($reconcile) && (int) ($reconcile['ok'] ?? 0) === 1) {
|
|
$this->persistConsentPhaseOnRequestRow($anchorRowId, $reconcile);
|
|
}
|
|
|
|
$currentStatus = $liveStatus !== '' ? $liveStatus : strtoupper(trim((string) ($checkWf['workflow_state'] ?? '')));
|
|
|
|
// Verify whether this specific consent request is expired under ABDM dataEraseAt rules
|
|
$isTargetExpired = ($currentStatus === 'EXPIRED');
|
|
$targetEraseAt = '';
|
|
if (! $isTargetExpired && is_array($checkWf)) {
|
|
$targetEraseAt = $this->extractDataEraseAtFromWorkflowRow($checkWf);
|
|
if ($targetEraseAt !== '') {
|
|
$targetTs = strtotime($targetEraseAt);
|
|
if ($targetTs !== false && $targetTs <= time()) {
|
|
$isTargetExpired = true;
|
|
$currentStatus = 'EXPIRED';
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($isTargetExpired || in_array($currentStatus, ['EXPIRED', 'DENIED', 'REVOKED'], true)) {
|
|
$statusWord = $currentStatus !== '' ? $currentStatus : 'EXPIRED';
|
|
$relatedPurgeIds = $this->resolveAllRelatedConsentIds($lookupTarget);
|
|
$this->purgeAbdmDocumentsByRefs(array_merge([$lookupTarget], $relatedPurgeIds));
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => $statusWord,
|
|
'status' => $statusWord,
|
|
'error' => "This consent request is {$statusWord}. Under ABDM guidelines, records cannot be fetched or accessed under an {$statusWord} consent. Please create a new consent request.",
|
|
]);
|
|
}
|
|
|
|
if (! in_array($currentStatus, ['GRANTED', 'COMPLETED', 'ACTIVE', 'APPROVED'], true)) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => $currentStatus !== '' ? $currentStatus : 'REQUESTED',
|
|
'status' => $currentStatus !== '' ? $currentStatus : 'REQUESTED',
|
|
'error' => "Consent is not granted yet (current status: {$currentStatus}). Once approved by the patient in their ABHA app, please try again.",
|
|
]);
|
|
}
|
|
|
|
$phase = 'GRANTED';
|
|
} else {
|
|
$lastSync = $this->getLatestAbdmSyncSnapshot((string) ($abhaContext['abha_address'] ?? ''));
|
|
$phase = strtoupper(trim((string) ($lastSync['phase'] ?? '')));
|
|
if ($phase === 'EXPIRED') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => 'EXPIRED',
|
|
'error' => 'The latest consent request has expired. Under ABDM guidelines, records cannot be fetched or accessed under an expired consent. Please create a new consent request.',
|
|
]);
|
|
}
|
|
if (! in_array($phase, ['GRANTED', 'COMPLETED'], true)) {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => $phase !== '' ? $phase : 'IDLE',
|
|
'error' => 'Consent is not granted yet. Use "+ New Request" to send/track a consent request first.',
|
|
]);
|
|
}
|
|
|
|
$consentRequestRef = trim((string) ($lastSync['consent_request_id'] ?? ''));
|
|
$consentArtifactRef = trim((string) ($lastSync['consent_id'] ?? ''));
|
|
}
|
|
|
|
if ($consentArtifactRef === '' && $consentRequestRef === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => $phase,
|
|
'error' => 'No saved consent reference found. Use "+ New Request" to re-establish consent.',
|
|
]);
|
|
}
|
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
session_write_close();
|
|
}
|
|
|
|
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
|
|
|
|
// Resolve the umbrella consent_request_id so all sibling facilities (both same
|
|
// entity and external entities) are fetched and decrypted.
|
|
$umbrellaRequestId = $consentRequestRef;
|
|
if ($umbrellaRequestId === '' && $consentArtifactRef !== '' && $this->db->tableExists('abdm_hiu_consent_artifacts')) {
|
|
$foundArt = $this->db->table('abdm_hiu_consent_artifacts')
|
|
->select('consent_request_id')
|
|
->where('artifact_id', $consentArtifactRef)
|
|
->get(1)->getRowArray();
|
|
if (! empty($foundArt['consent_request_id'])) {
|
|
$umbrellaRequestId = trim((string) $foundArt['consent_request_id']);
|
|
}
|
|
}
|
|
|
|
// First, execute multi-facility cascade fetch so all sibling facilities are queried and decrypted
|
|
$multiFetchCascade = null;
|
|
if ($umbrellaRequestId !== '') {
|
|
try {
|
|
$cascadePayload = [
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
'consent_request_id' => $umbrellaRequestId,
|
|
'abdm_consent_request_id' => $umbrellaRequestId,
|
|
'consent_id' => $consentArtifactRef !== '' ? $consentArtifactRef : $umbrellaRequestId,
|
|
'hfr_id' => trim((string) ($lastSync['hfr_id'] ?? '')),
|
|
];
|
|
$simulatedConsentResult = [
|
|
'workflow_state' => 'GRANTED',
|
|
'status' => 'GRANTED',
|
|
'consent_status' => 'GRANTED',
|
|
'abdm_consent_request_id' => $umbrellaRequestId,
|
|
'consent_request_id' => $umbrellaRequestId,
|
|
'abdm_consent_artifact_id'=> $consentArtifactRef,
|
|
'consent_id' => $consentArtifactRef !== '' ? $consentArtifactRef : $umbrellaRequestId,
|
|
];
|
|
$multiFetchCascade = $service->fetchAllArtifactsAfterGrant($cascadePayload, $simulatedConsentResult);
|
|
} catch (\Throwable $e) {
|
|
log_message('warning', '[abdm_content_fetch_only] fetchAllArtifactsAfterGrant error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$fetchPayload = [
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
];
|
|
if ($consentArtifactRef !== '') {
|
|
$fetchPayload['consentId'] = $consentArtifactRef;
|
|
}
|
|
// Always include the request id too (when known), purely so any documents
|
|
// persisted from this fetch can record which consent request they came
|
|
// from. This does not change the actual bridge query for Method 2, which
|
|
// is driven only by consentId when both are present.
|
|
if ($consentRequestRef !== '') {
|
|
$fetchPayload['consentRequestId'] = $consentRequestRef;
|
|
} elseif ($umbrellaRequestId !== '') {
|
|
$fetchPayload['consentRequestId'] = $umbrellaRequestId;
|
|
}
|
|
|
|
try {
|
|
$fetchResult = $service->runOperation('data_fetch', $fetchPayload);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => 'FETCH_FAILED',
|
|
'error' => 'Unable to fetch ABDM content: ' . $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
if ((int) ($fetchResult['ok'] ?? 0) !== 1) {
|
|
$httpCode = (int) ($fetchResult['http_code'] ?? 422);
|
|
if ($httpCode < 100 || $httpCode > 599) {
|
|
$httpCode = 422;
|
|
}
|
|
|
|
$fetchError = (string) ($fetchResult['error_text'] ?? 'Data fetch failed.');
|
|
if ($this->isPendingBridgeSessionError($fetchError, $httpCode)) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'FETCH_PENDING',
|
|
'message' => 'No health records have arrived for this consent yet. Try again later.',
|
|
'data' => ['data_fetch' => $fetchResult],
|
|
]);
|
|
}
|
|
|
|
return $this->response->setStatusCode($httpCode)->setJSON([
|
|
'ok' => 0,
|
|
'phase' => 'FETCH_FAILED',
|
|
'error' => $fetchError,
|
|
'data' => ['data_fetch' => $fetchResult],
|
|
]);
|
|
}
|
|
|
|
// `$fetchResult['ok'] === 1` only means the API call to the bridge
|
|
// succeeded (no HTTP/transport error) — it does NOT mean the HIP has
|
|
// actually sent any health records yet (the bridge may legitimately
|
|
// respond with an empty decrypted_data set while still processing).
|
|
// Only report success to the user once documents were actually saved;
|
|
// otherwise say plainly that the fetch is still pending.
|
|
$documentsPersisted = (int) ($fetchResult['documents_persisted'] ?? 0);
|
|
$documentsUpdated = (int) ($fetchResult['documents_updated'] ?? 0);
|
|
|
|
// The consent-artifact-scoped fetch above (bridge "Method 2") may resolve
|
|
// to a stale/different session sharing the same consent artifact id. Per
|
|
// the bridge gateway team, GET /v1/hiu/data/fetch?request_id=... ("Method
|
|
// 1") targets the ONE specific consent request instead -- this is the
|
|
// "self"/current session's own data. Try it before falling back to the
|
|
// broader by-ABHA-address historical lookup (Method 3).
|
|
if ($documentsPersisted + $documentsUpdated === 0 && $consentRequestRef !== '') {
|
|
try {
|
|
$requestIdFetchResult = $service->runOperation('data_fetch', [
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
'consentRequestId' => $consentRequestRef,
|
|
'fetch_by_request_id' => 1,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
$requestIdFetchResult = null;
|
|
}
|
|
|
|
if (is_array($requestIdFetchResult) && (int) ($requestIdFetchResult['ok'] ?? 0) === 1) {
|
|
$reqDocumentsPersisted = (int) ($requestIdFetchResult['documents_persisted'] ?? 0);
|
|
$reqDocumentsUpdated = (int) ($requestIdFetchResult['documents_updated'] ?? 0);
|
|
if ($reqDocumentsPersisted + $reqDocumentsUpdated > 0) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'COMPLETED',
|
|
'message' => 'Records fetched successfully using existing granted consent.',
|
|
'documents_persisted' => $reqDocumentsPersisted,
|
|
'documents_updated' => $reqDocumentsUpdated,
|
|
'data' => [
|
|
'data_fetch' => $this->sanitizeFetchResultForResponse($fetchResult),
|
|
'data_fetch_by_request_id' => $this->sanitizeFetchResultForResponse($requestIdFetchResult),
|
|
],
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The consent-artifact-scoped fetch above (bridge "Method 1/2") only
|
|
// returns data for the ONE consent/request we targeted. Per the bridge
|
|
// gateway team, GET /v1/hiu/data/fetch?abha_address=... ("Method 3")
|
|
// separately returns ALL historical decrypted records already available
|
|
// at the bridge across every past consent for this patient. If the
|
|
// consent-scoped call above found nothing new, fall back to this
|
|
// broader by-ABHA-address lookup before telling the user there is
|
|
// truly nothing to fetch yet.
|
|
if ($documentsPersisted + $documentsUpdated === 0) {
|
|
try {
|
|
$abhaFetchResult = $service->runOperation('data_fetch', [
|
|
'abha_address' => $abhaContext['abha_address'],
|
|
'fetch_by_abha_address' => 1,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
$abhaFetchResult = null;
|
|
}
|
|
|
|
if (is_array($abhaFetchResult) && (int) ($abhaFetchResult['ok'] ?? 0) === 1) {
|
|
$abhaDocumentsPersisted = (int) ($abhaFetchResult['documents_persisted'] ?? 0);
|
|
$abhaDocumentsUpdated = (int) ($abhaFetchResult['documents_updated'] ?? 0);
|
|
if ($abhaDocumentsPersisted + $abhaDocumentsUpdated > 0) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'COMPLETED',
|
|
'message' => 'Records fetched successfully from historical ABDM records for this ABHA address.',
|
|
'documents_persisted' => $abhaDocumentsPersisted,
|
|
'documents_updated' => $abhaDocumentsUpdated,
|
|
'data' => [
|
|
'data_fetch' => $this->sanitizeFetchResultForResponse($fetchResult),
|
|
'data_fetch_by_abha_address' => $this->sanitizeFetchResultForResponse($abhaFetchResult),
|
|
],
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($documentsPersisted + $documentsUpdated === 0) {
|
|
// Check if records already exist locally for this consent session (e.g. from prior polling or sibling fetch)
|
|
$existingDocCount = $this->countAbdmDocumentsForPatient($pno, $umbrellaRequestId !== '' ? $umbrellaRequestId : $consentArtifactRef);
|
|
if ($existingDocCount > 0) {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'COMPLETED',
|
|
'message' => 'Records fetched successfully using existing granted consent.',
|
|
'documents_persisted' => $existingDocCount,
|
|
'documents_updated' => (int) ($multiFetchCascade['data_updates'] ?? 0),
|
|
'data' => [
|
|
'data_fetch' => $this->sanitizeFetchResultForResponse($fetchResult),
|
|
'cascade' => $multiFetchCascade,
|
|
],
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'GRANTED',
|
|
'message' => 'Consent is granted, but the health information provider has not sent any records yet. Please try again in a few minutes.',
|
|
'documents_persisted' => 0,
|
|
'documents_updated' => 0,
|
|
'data' => ['data_fetch' => $this->sanitizeFetchResultForResponse($fetchResult)],
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'phase' => 'COMPLETED',
|
|
'message' => 'Records fetched successfully using existing granted consent.',
|
|
'documents_persisted' => $documentsPersisted,
|
|
'documents_updated' => $documentsUpdated,
|
|
'data' => ['data_fetch' => $this->sanitizeFetchResultForResponse($fetchResult)],
|
|
]);
|
|
}
|
|
|
|
private function sanitizeFetchResultForResponse(array $result): array
|
|
{
|
|
$clean = $result;
|
|
if (isset($clean['sessions']) && is_array($clean['sessions'])) {
|
|
$clean['sessions_count'] = count($clean['sessions']);
|
|
unset($clean['sessions']);
|
|
}
|
|
if (isset($clean['data']) && is_array($clean['data']) && isset($clean['data']['sessions'])) {
|
|
unset($clean['data']['sessions']);
|
|
}
|
|
return $clean;
|
|
}
|
|
|
|
/**
|
|
* Consent detail breakdown for the "View Consent" modal — shows the requested vs
|
|
* granted Health Information Types for the current consent artifact, plus the
|
|
* Requested -> Granted -> Revoked/Expired lifecycle timestamps for each type.
|
|
*/
|
|
public function abdm_consent_detail(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
$abhaAddress = trim((string) ($abhaContext['abha_address'] ?? ''));
|
|
if ($abhaAddress === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA Address not available for this patient.',
|
|
]);
|
|
}
|
|
|
|
$detail = $this->getAbdmConsentArtifactDetail($abhaAddress);
|
|
if ((int) ($detail['ok'] ?? 0) !== 1) {
|
|
return $this->response->setStatusCode(404)->setJSON($detail);
|
|
}
|
|
|
|
return $this->response->setJSON($detail);
|
|
}
|
|
|
|
/**
|
|
* Full history of this patient's ABDM consent requests (one entry per distinct
|
|
* consent request "session"), most recent first — used by the "Consent Request
|
|
* History" table on the OPD ABDM tab.
|
|
*/
|
|
public function abdm_consent_requests(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
$abhaAddress = trim((string) ($abhaContext['abha_address'] ?? ''));
|
|
if ($abhaAddress === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA Address not available for this patient.',
|
|
]);
|
|
}
|
|
|
|
$rawList = $this->getAbdmConsentRequestsList($abhaAddress);
|
|
$latestRequest = $rawList['requests'][0] ?? null;
|
|
if ($latestRequest && ! in_array(strtoupper(trim((string) ($latestRequest['status'] ?? ''))), ['EXPIRED', 'DENIED', 'REVOKED'], true)) {
|
|
$sessionRequestId = trim((string) ($latestRequest['request_id'] ?? ''));
|
|
$sessionConsentRequestId = trim((string) ($latestRequest['consent_request_id'] ?? ''));
|
|
$rawConsentId = trim((string) ($latestRequest['consent_id'] ?? ''));
|
|
$sessionConsentId = $rawConsentId !== '' ? trim((string) explode(':', $rawConsentId)[0]) : '';
|
|
$anchorRowId = (int) ($latestRequest['id'] ?? 0);
|
|
if ($sessionRequestId !== '' || $sessionConsentRequestId !== '' || $sessionConsentId !== '') {
|
|
try {
|
|
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
|
|
$reconcile = $service->runOperation('consent_reconcile', [
|
|
'abha_address' => $abhaAddress,
|
|
'request_id' => $sessionRequestId,
|
|
'abdm_consent_request_id' => $sessionConsentRequestId,
|
|
'abdm_consent_artifact_id'=> $sessionConsentId,
|
|
'consent_id' => $sessionConsentId,
|
|
]);
|
|
if ((int) ($reconcile['ok'] ?? 0) === 1 && $anchorRowId > 0) {
|
|
$this->persistConsentPhaseOnRequestRow($anchorRowId, $reconcile);
|
|
$rawList = $this->getAbdmConsentRequestsList($abhaAddress);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Silent fallback to cached list
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->response->setJSON($rawList);
|
|
}
|
|
|
|
/**
|
|
* Live status check — actively calls the ABDM bridge's
|
|
* GET /v1/hiu/consent/status (via M3HiuWorkflowService's consent_reconcile
|
|
* operation) for this patient's most recent consent request instead of
|
|
* only re-reading whatever was last saved in abdm_hiu_workflows. Needed
|
|
* because HMS otherwise only learns about a GRANTED consent when a cron
|
|
* (`php spark abdm:hiu-poll`) or a webhook callback updates the DB — if
|
|
* neither has run yet, the UI can keep showing a stale REQUESTED status
|
|
* even though the bridge/ABDM sandbox already shows GRANTED. If the
|
|
* reconcile confirms GRANTED, this also cascades into fetching decrypted
|
|
* data for EVERY known consent artifact under this consent request (one
|
|
* per linked HIP facility) via M3HiuWorkflowService::fetchAllArtifactsAfterGrant(),
|
|
* so a manual click surfaces ALL facilities' records instead of just one.
|
|
*/
|
|
public function abdm_check_live_status(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
$abhaAddress = trim((string) ($abhaContext['abha_address'] ?? ''));
|
|
if ($abhaAddress === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'ABHA Address not available for this patient.',
|
|
]);
|
|
}
|
|
|
|
// Every non-terminal session is reconciled, not just the newest one:
|
|
// older requests the patient later denied/expired in the PHR app keep
|
|
// showing REQUESTED otherwise, because nothing ever re-checks them.
|
|
$sessions = $this->getAbdmConsentRequestsList($abhaAddress);
|
|
$reconcileJobs = [];
|
|
foreach ($sessions['requests'] ?? [] as $consent) {
|
|
if (in_array(strtoupper(trim((string) ($consent['status'] ?? ''))), ['EXPIRED', 'DENIED', 'REVOKED'], true)) {
|
|
continue;
|
|
}
|
|
$sessionRequestId = trim((string) ($consent['request_id'] ?? ''));
|
|
$sessionGatewayRequestId = trim((string) ($consent['gateway_request_id'] ?? ''));
|
|
$sessionConsentRequestId = trim((string) ($consent['consent_request_id'] ?? ''));
|
|
$rawConsentId = trim((string) ($consent['consent_id'] ?? ''));
|
|
$sessionConsentId = $rawConsentId !== '' ? trim((string) explode(':', $rawConsentId)[0]) : '';
|
|
if ($sessionRequestId === '' && $sessionGatewayRequestId === '' && $sessionConsentRequestId === '' && $sessionConsentId === '') {
|
|
continue;
|
|
}
|
|
$reconcileJobs[] = [
|
|
'anchor_row_id' => (int) ($consent['id'] ?? 0),
|
|
'payload' => [
|
|
'abha_address' => $abhaAddress,
|
|
'request_id' => $sessionRequestId,
|
|
'gateway_request_id' => $sessionGatewayRequestId,
|
|
'abdm_consent_request_id' => $sessionConsentRequestId,
|
|
'abdm_consent_artifact_id' => $sessionConsentId,
|
|
'consent_id' => $sessionConsentId,
|
|
],
|
|
];
|
|
}
|
|
|
|
if ($reconcileJobs === []) {
|
|
$snapshot = $this->getLatestAbdmSyncSnapshot($abhaAddress);
|
|
$requestId = trim((string) ($snapshot['request_id'] ?? ''));
|
|
$consentRequestId = trim((string) ($snapshot['consent_request_id'] ?? ''));
|
|
$rawConsentId = trim((string) ($snapshot['consent_id'] ?? ''));
|
|
$consentId = $rawConsentId !== '' ? trim((string) explode(':', $rawConsentId)[0]) : '';
|
|
if ($requestId === '' && $consentRequestId === '' && $consentId === '') {
|
|
return $this->response->setStatusCode(422)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'No consent request found for this patient to check.',
|
|
]);
|
|
}
|
|
$reconcileJobs[] = [
|
|
'anchor_row_id' => 0,
|
|
'payload' => [
|
|
'abha_address' => $abhaAddress,
|
|
'request_id' => $requestId,
|
|
'abdm_consent_request_id' => $consentRequestId,
|
|
'abdm_consent_artifact_id' => $consentId,
|
|
'consent_id' => $consentId,
|
|
],
|
|
];
|
|
}
|
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
session_write_close();
|
|
}
|
|
|
|
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
|
|
$reconcileOk = false;
|
|
$reconcileError = '';
|
|
$granted = false;
|
|
|
|
$artifactsFetched = 0;
|
|
$dataFetchUpdates = 0;
|
|
$dataFetchFailed = 0;
|
|
|
|
// Limit check to the most recent active jobs (max 2) to keep live status instantaneous
|
|
$activeJobs = array_slice($reconcileJobs, 0, 2);
|
|
foreach ($activeJobs as $job) {
|
|
$reconcilePayload = $job['payload'];
|
|
try {
|
|
$reconcile = $service->runOperation('consent_reconcile', $reconcilePayload);
|
|
} catch (\Throwable $e) {
|
|
$reconcileError = $reconcileError !== '' ? $reconcileError : 'Unable to check live status: ' . $e->getMessage();
|
|
continue;
|
|
}
|
|
|
|
if ((int) ($reconcile['ok'] ?? 0) !== 1) {
|
|
$reconcileError = $reconcileError !== '' ? $reconcileError : (string) ($reconcile['error_text'] ?? 'Live status check failed.');
|
|
continue;
|
|
}
|
|
$reconcileOk = true;
|
|
$this->persistConsentPhaseOnRequestRow((int) $job['anchor_row_id'], $reconcile);
|
|
|
|
$status = strtoupper(trim((string) (
|
|
$reconcile['workflow_state']
|
|
?? $reconcile['status']
|
|
?? $reconcile['consent_status']
|
|
?? ''
|
|
)));
|
|
if ($status === 'GRANTED') {
|
|
$granted = true;
|
|
try {
|
|
$cascade = $service->fetchAllArtifactsAfterGrant($reconcilePayload, $reconcile);
|
|
$artifactsFetched += count($cascade['artifact_ids'] ?? []);
|
|
$dataFetchUpdates += (int) ($cascade['data_updates'] ?? 0);
|
|
$dataFetchFailed += (int) ($cascade['failed'] ?? 0);
|
|
} catch (\Throwable $ex) {
|
|
log_message('warning', '[abdm_check_live_status] fetchAllArtifactsAfterGrant error: ' . $ex->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! $reconcileOk && $reconcileError === '') {
|
|
$reconcileError = 'Live status check failed.';
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'sessions_checked' => count($activeJobs),
|
|
'reconcile_ok' => $reconcileOk ? 1 : 0,
|
|
'reconcile_error' => $reconcileOk ? '' : $reconcileError,
|
|
'granted' => $granted ? 1 : 0,
|
|
'artifacts_fetched' => $artifactsFetched,
|
|
'data_fetch_updates' => $dataFetchUpdates,
|
|
'data_fetch_failed' => $dataFetchFailed,
|
|
] + $this->getAbdmConsentRequestsList($abhaAddress));
|
|
}
|
|
|
|
/**
|
|
* Stamps a reconciled consent status onto the session's own CONSENT_REQUEST
|
|
* row. Without this the status lives only on a separate reconcile row, whose
|
|
* attribution back to a session depends on id correlation — so a reload could
|
|
* show REQUESTED again for a request ABDM already reported as denied/expired.
|
|
*
|
|
* @param array<string, mixed> $reconcile consent_reconcile operation result
|
|
*/
|
|
private function persistConsentPhaseOnRequestRow(int $rowId, array $reconcile): void
|
|
{
|
|
if ($rowId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$status = '';
|
|
if (isset($reconcile['consent']) && is_array($reconcile['consent'])) {
|
|
$status = trim((string) (
|
|
$reconcile['consent']['status']
|
|
?? $reconcile['consent']['consent_status']
|
|
?? $reconcile['consent']['consentStatus']
|
|
?? ''
|
|
));
|
|
}
|
|
if ($status === '' && isset($reconcile['consentDetail']) && is_array($reconcile['consentDetail'])) {
|
|
$status = trim((string) ($reconcile['consentDetail']['status'] ?? ''));
|
|
}
|
|
if ($status === '' && isset($reconcile['data']['consent']) && is_array($reconcile['data']['consent'])) {
|
|
$status = trim((string) ($reconcile['data']['consent']['status'] ?? ''));
|
|
}
|
|
if ($status === '') {
|
|
$status = trim((string) ($reconcile['consent_status'] ?? $reconcile['consentStatus'] ?? ''));
|
|
}
|
|
if ($status === '' || in_array(strtolower($status), ['success', 'ok', 'failed', 'error', 'status_checked'], true)) {
|
|
$status = trim((string) ($reconcile['workflow_state'] ?? ''));
|
|
}
|
|
if ($status === '' || in_array(strtolower($status), ['success', 'ok', 'failed', 'error', 'status_checked'], true)) {
|
|
$topStatus = trim((string) ($reconcile['status'] ?? ''));
|
|
if (! in_array(strtolower($topStatus), ['success', 'ok', 'failed', 'error', 'status_checked', '1', '0'], true)) {
|
|
$status = $topStatus;
|
|
}
|
|
}
|
|
|
|
$status = strtoupper($status);
|
|
$state = match ($status) {
|
|
'GRANTED', 'APPROVED', 'ACTIVE' => 'GRANTED',
|
|
'DENIED' => 'DENIED',
|
|
'EXPIRED' => 'EXPIRED',
|
|
'REVOKED' => 'REVOKED',
|
|
default => '',
|
|
};
|
|
if ($state === '') {
|
|
return;
|
|
}
|
|
|
|
$now = date('Y-m-d H:i:s');
|
|
$update = ['workflow_state' => $state, 'updated_at' => $now];
|
|
if ($state === 'EXPIRED') {
|
|
$update['expired_at'] = $now;
|
|
} elseif ($state === 'REVOKED' || $state === 'DENIED') {
|
|
$update['revoked_at'] = $now;
|
|
}
|
|
|
|
try {
|
|
$this->db->table('abdm_hiu_workflows')->where('id', $rowId)->update($update);
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Unable to persist ABDM consent status on row ' . $rowId . ': ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when the bridge is saying "this reference doesn't resolve to a session
|
|
* yet" rather than reporting a real failure. The bridge uses several wordings
|
|
* depending on which lookup key was sent, and returns 400 as often as 404.
|
|
*/
|
|
private function isPendingBridgeSessionError(string $errorText, int $httpCode): bool
|
|
{
|
|
return in_array($httpCode, [400, 404], true)
|
|
&& (stripos($errorText, 'Consent record not found') !== false
|
|
|| stripos($errorText, 'NOT_FOUND') !== false
|
|
|| stripos($errorText, 'No HIU sessions found') !== false
|
|
|| stripos($errorText, 'no sessions found') !== false
|
|
|| stripos($errorText, 'matching the criteria') !== false);
|
|
}
|
|
|
|
public function abdm_timeline(int $pno)
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setStatusCode(500)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'patient_master table not found',
|
|
]);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
|
|
if (! is_array($patientRow)) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'ok' => 0,
|
|
'error' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$abhaContext = $this->buildAbhaProfileContext($patientRow);
|
|
if ($abhaContext['abha_address'] === '') {
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'count' => 0,
|
|
'items' => [],
|
|
'note' => 'ABHA address not available for timeline filter.',
|
|
]);
|
|
}
|
|
|
|
$limit = (int) ($this->request->getGet('limit') ?? 15);
|
|
if ($limit <= 0 || $limit > 100) {
|
|
$limit = 15;
|
|
}
|
|
|
|
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
|
|
$rows = $service->listTimeline([
|
|
'abha_address' => (string) $abhaContext['abha_address'],
|
|
], $limit);
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'count' => count($rows),
|
|
'items' => $rows,
|
|
]);
|
|
}
|
|
|
|
public function save_profile_image(int $pid)
|
|
{
|
|
$patient = $this->db->table('patient_master')->where('id', $pid)->get()->getRow();
|
|
if (!$patient) {
|
|
return $this->response->setStatusCode(404)->setJSON([
|
|
'success' => false,
|
|
'message' => 'Patient not found',
|
|
]);
|
|
}
|
|
|
|
$dataUri = (string) ($this->request->getPost('image') ?? $this->request->getPost('webcam') ?? $this->request->getPost('data_uri'));
|
|
if ($dataUri === '') {
|
|
$raw = (string) $this->request->getBody();
|
|
if (str_starts_with($raw, 'data:image')) {
|
|
$dataUri = $raw;
|
|
}
|
|
}
|
|
|
|
if ($dataUri === '' || !str_contains($dataUri, 'base64,')) {
|
|
return $this->response->setJSON([
|
|
'success' => false,
|
|
'message' => 'Invalid image data.',
|
|
]);
|
|
}
|
|
|
|
[$meta, $encoded] = explode('base64,', $dataUri, 2);
|
|
$mime = 'image/jpeg';
|
|
if (preg_match('/data:(image\/[a-zA-Z0-9.+-]+);/i', $meta, $match)) {
|
|
$mime = strtolower($match[1]);
|
|
}
|
|
|
|
$binary = base64_decode($encoded);
|
|
if ($binary === false) {
|
|
return $this->response->setJSON([
|
|
'success' => false,
|
|
'message' => 'Unable to decode image.',
|
|
]);
|
|
}
|
|
|
|
$extension = match ($mime) {
|
|
'image/png' => 'png',
|
|
'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
default => 'jpg',
|
|
};
|
|
|
|
$uploadPath = rtrim(FCPATH, '\\/') . '/uploads/patient';
|
|
if (!is_dir($uploadPath)) {
|
|
mkdir($uploadPath, 0755, true);
|
|
}
|
|
|
|
$filename = 'profile_' . $pid . '_' . time() . '.' . $extension;
|
|
$fullPath = $uploadPath . '/' . $filename;
|
|
if (file_put_contents($fullPath, $binary) === false) {
|
|
return $this->response->setJSON([
|
|
'success' => false,
|
|
'message' => 'Unable to save image.',
|
|
]);
|
|
}
|
|
|
|
$publicPath = '/uploads/patient/' . $filename;
|
|
$insertId = $this->insertFileUploadRecordFromData($pid, 'profile', $publicPath, $mime, strlen($binary));
|
|
|
|
$updated = false;
|
|
if ($insertId > 0) {
|
|
$this->db->table('patient_master')
|
|
->where('id', $pid)
|
|
->update(['profile_file_id' => $insertId]);
|
|
$updated = true;
|
|
}
|
|
|
|
if ($this->db->fieldExists('profile_picture', 'patient_master')) {
|
|
$this->db->table('patient_master')
|
|
->where('id', $pid)
|
|
->update(['profile_picture' => $publicPath]);
|
|
$updated = true;
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'success' => $updated,
|
|
'path' => $publicPath,
|
|
'message' => $updated ? 'Profile image saved.' : 'Image saved, record not updated.',
|
|
'csrf' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
public function patient_file_upload(int $pid)
|
|
{
|
|
$patient = $this->db->table('patient_master')->where('id', $pid)->get()->getRow();
|
|
if (!$patient) {
|
|
return $this->response->setStatusCode(404)->setBody('Patient not found');
|
|
}
|
|
|
|
$docType = (string) $this->request->getPost('doc_type');
|
|
$updateProfile = $this->request->getPost('update_profile') === '1';
|
|
$message = '';
|
|
|
|
$file = $this->request->getFile('upload_file');
|
|
if ($file && $file->isValid() && !$file->hasMoved()) {
|
|
$ext = strtolower((string) ($file->getClientExtension() ?: pathinfo((string) $file->getName(), PATHINFO_EXTENSION)));
|
|
$allowedExts = ['pdf', 'jpg', 'jpeg', 'png', 'webp', 'doc', 'docx'];
|
|
if (! in_array($ext, $allowedExts, true)) {
|
|
$message = 'Invalid file format. Upload PDF, image, or document only.';
|
|
} else {
|
|
$uploadPath = rtrim(FCPATH, '\\/') . '/uploads/patient';
|
|
if (!is_dir($uploadPath)) {
|
|
mkdir($uploadPath, 0755, true);
|
|
}
|
|
|
|
$newName = $file->getRandomName();
|
|
if ($file->move($uploadPath, $newName)) {
|
|
$publicPath = '/uploads/patient/' . $newName;
|
|
$insertId = $this->insertFileUploadRecord($pid, $docType, $publicPath, $file);
|
|
|
|
if ($updateProfile && $insertId > 0) {
|
|
$this->db->table('patient_master')
|
|
->where('id', $pid)
|
|
->update(['profile_file_id' => $insertId]);
|
|
}
|
|
|
|
$message = $insertId > 0 ? 'File uploaded successfully.' : 'File uploaded, but record was not saved.';
|
|
} else {
|
|
$message = 'Unable to save the file.';
|
|
}
|
|
}
|
|
} elseif ($file && !$file->isValid()) {
|
|
$message = $file->getErrorString();
|
|
}
|
|
|
|
return view('billing/Patient_File_Upload_V', [
|
|
'patient' => $patient,
|
|
'docType' => $docType,
|
|
'updateProfile' => $updateProfile,
|
|
'message' => $message,
|
|
]);
|
|
}
|
|
|
|
public function city()
|
|
{
|
|
$q = strtolower((string) $this->request->getGet('term'));
|
|
if ($q === '') {
|
|
return $this->response->setJSON([]);
|
|
}
|
|
|
|
$patientModel = new PatientModel();
|
|
return $this->response->setJSON($patientModel->getCitySuggestions($q));
|
|
}
|
|
|
|
public function district_list()
|
|
{
|
|
$q = trim((string) $this->request->getGet('term'));
|
|
if ($q === '' || strlen($q) < 2) {
|
|
return $this->response->setJSON([]);
|
|
}
|
|
|
|
if (! $this->db->tableExists('abdm_district_master')) {
|
|
return $this->response->setJSON([]);
|
|
}
|
|
|
|
$rows = $this->db->table('abdm_district_master')
|
|
->select('district_name AS value, district_name AS label, state_code')
|
|
->like('district_name', $q)
|
|
->orderBy('district_name', 'ASC')
|
|
->limit(15)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
return $this->response->setJSON($rows);
|
|
}
|
|
|
|
public function get_name()
|
|
{
|
|
$q = strtolower((string) $this->request->getGet('term'));
|
|
if ($q === '') {
|
|
return $this->response->setJSON([]);
|
|
}
|
|
|
|
$patientModel = new PatientModel();
|
|
return $this->response->setJSON($patientModel->getNameSuggestions($q));
|
|
}
|
|
|
|
private function getProfileFilePath(int $fileId): string
|
|
{
|
|
$profileFilePath = '/assets/images/no_image.svg';
|
|
if ($fileId <= 0) {
|
|
return $profileFilePath;
|
|
}
|
|
|
|
$query = $this->db->table('file_upload_data')->where('id', $fileId)->get();
|
|
$row = $query->getRow();
|
|
if ($row && isset($row->full_path)) {
|
|
$path = (string) $row->full_path;
|
|
if ($path !== '') {
|
|
$profileFilePath = $path;
|
|
}
|
|
}
|
|
|
|
return $profileFilePath;
|
|
}
|
|
|
|
private function insertFileUploadRecord(int $pid, string $docType, string $publicPath, $file): int
|
|
{
|
|
if (!$this->db->tableExists('file_upload_data')) {
|
|
return 0;
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('file_upload_data');
|
|
if (!$fields) {
|
|
return 0;
|
|
}
|
|
|
|
$data = [];
|
|
if (in_array('p_id', $fields, true)) {
|
|
$data['p_id'] = $pid;
|
|
}
|
|
if (in_array('attach_id', $fields, true)) {
|
|
$data['attach_id'] = $pid;
|
|
}
|
|
if (in_array('doc_type', $fields, true)) {
|
|
$data['doc_type'] = $docType;
|
|
}
|
|
if (in_array('file_name', $fields, true)) {
|
|
$data['file_name'] = $file->getClientName();
|
|
}
|
|
if (in_array('file_type', $fields, true)) {
|
|
$data['file_type'] = $file->getClientMimeType();
|
|
}
|
|
if (in_array('file_size', $fields, true)) {
|
|
$data['file_size'] = $file->getSize();
|
|
}
|
|
if (in_array('full_path', $fields, true)) {
|
|
$data['full_path'] = $publicPath;
|
|
}
|
|
if (in_array('insert_date', $fields, true)) {
|
|
$data['insert_date'] = date('Y-m-d H:i:s');
|
|
}
|
|
if (in_array('created_at', $fields, true)) {
|
|
$data['created_at'] = date('Y-m-d H:i:s');
|
|
}
|
|
|
|
if (!$data) {
|
|
return 0;
|
|
}
|
|
|
|
$builder = $this->db->table('file_upload_data');
|
|
if (!$builder->insert($data)) {
|
|
return 0;
|
|
}
|
|
|
|
return (int) $this->db->insertID();
|
|
}
|
|
|
|
private function insertFileUploadRecordFromData(int $pid, string $docType, string $publicPath, string $mime, int $size): int
|
|
{
|
|
if (!$this->db->tableExists('file_upload_data')) {
|
|
return 0;
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('file_upload_data');
|
|
if (!$fields) {
|
|
return 0;
|
|
}
|
|
|
|
$data = [];
|
|
if (in_array('p_id', $fields, true)) {
|
|
$data['p_id'] = $pid;
|
|
}
|
|
if (in_array('attach_id', $fields, true)) {
|
|
$data['attach_id'] = $pid;
|
|
}
|
|
if (in_array('doc_type', $fields, true)) {
|
|
$data['doc_type'] = $docType;
|
|
}
|
|
if (in_array('file_name', $fields, true)) {
|
|
$data['file_name'] = basename($publicPath);
|
|
}
|
|
if (in_array('file_type', $fields, true)) {
|
|
$data['file_type'] = $mime;
|
|
}
|
|
if (in_array('file_size', $fields, true)) {
|
|
$data['file_size'] = $size;
|
|
}
|
|
if (in_array('full_path', $fields, true)) {
|
|
$data['full_path'] = $publicPath;
|
|
}
|
|
if (in_array('insert_date', $fields, true)) {
|
|
$data['insert_date'] = date('Y-m-d H:i:s');
|
|
}
|
|
if (in_array('created_at', $fields, true)) {
|
|
$data['created_at'] = date('Y-m-d H:i:s');
|
|
}
|
|
|
|
if (!$data) {
|
|
return 0;
|
|
}
|
|
|
|
$builder = $this->db->table('file_upload_data');
|
|
if (!$builder->insert($data)) {
|
|
return 0;
|
|
}
|
|
|
|
return (int) $this->db->insertID();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $result
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function enrichAbhaVerifyResult(array $result, int $requestedPid): array
|
|
{
|
|
$payload = [];
|
|
if (isset($result['data']) && is_array($result['data'])) {
|
|
$payload = $result['data'];
|
|
} elseif (is_array($result)) {
|
|
$payload = $result;
|
|
}
|
|
|
|
$profile = $this->pickGatewayAbhaProfile($payload);
|
|
$gatewayPatientPayload = is_array($payload['gateway_patient'] ?? null) ? $payload['gateway_patient'] : [];
|
|
$abhaNumberRaw = trim((string) ($profile['ABHANumber'] ?? $profile['abha_id'] ?? $payload['ABHANumber'] ?? $payload['abha_id'] ?? ''));
|
|
$abhaDigits = preg_replace('/\D/', '', $abhaNumberRaw);
|
|
$abhaAddress = trim((string) (
|
|
$profile['preferredAddress']
|
|
?? $profile['preferredAbhaAddress']
|
|
?? $profile['abha_address']
|
|
?? $payload['abha_address']
|
|
?? $payload['preferredAddress']
|
|
?? $payload['preferredAbhaAddress']
|
|
?? ''
|
|
));
|
|
$gatewayName = trim((string) ($profile['name'] ?? $profile['fullName'] ?? $profile['full_name'] ?? $payload['name'] ?? $payload['full_name'] ?? ''));
|
|
$gatewayMobile = trim((string) ($profile['mobile'] ?? $payload['mobile'] ?? $payload['mobileNumber'] ?? ''));
|
|
$gatewayDob = trim((string) ($profile['dob'] ?? $profile['date_of_birth'] ?? $payload['dob'] ?? $payload['date_of_birth'] ?? ''));
|
|
$gatewayGender = trim((string) ($profile['gender'] ?? $payload['gender'] ?? ''));
|
|
$gatewayPhoto = trim((string) ($profile['profilePhoto'] ?? $profile['profile_photo'] ?? $payload['profilePhoto'] ?? $payload['profile_photo'] ?? ''));
|
|
$gatewayEmail = trim((string) ($profile['email'] ?? $payload['email'] ?? ''));
|
|
$gatewayAddress = trim((string) (
|
|
$profile['address']
|
|
?? $profile['address_line']
|
|
?? $payload['address']
|
|
?? $payload['address_line']
|
|
?? ($gatewayPatientPayload['address_line'] ?? '')
|
|
?? ''
|
|
));
|
|
$gatewayZip = trim((string) (
|
|
$profile['pinCode']
|
|
?? $profile['pin_code']
|
|
?? $payload['pinCode']
|
|
?? $payload['pin_code']
|
|
?? $payload['pincode']
|
|
?? ($gatewayPatientPayload['pincode'] ?? '')
|
|
?? ''
|
|
));
|
|
$gatewayStateCode = trim((string) (
|
|
$profile['stateCode']
|
|
?? $profile['state_code']
|
|
?? $payload['stateCode']
|
|
?? $payload['state_code']
|
|
?? ($gatewayPatientPayload['state_code'] ?? '')
|
|
?? ''
|
|
));
|
|
$gatewayStateName = trim((string) (
|
|
$profile['stateName']
|
|
?? $profile['state_name']
|
|
?? $payload['stateName']
|
|
?? $payload['state_name']
|
|
?? ($gatewayPatientPayload['state_name'] ?? '')
|
|
?? ''
|
|
));
|
|
$gatewayDistrictCode = trim((string) (
|
|
$profile['districtCode']
|
|
?? $profile['district_code']
|
|
?? $payload['districtCode']
|
|
?? $payload['district_code']
|
|
?? ($payload['gateway_abha_profile']['district_code'] ?? '')
|
|
?? ''
|
|
));
|
|
$gatewayDistrictName = trim((string) (
|
|
$profile['districtName']
|
|
?? $profile['district_name']
|
|
?? $payload['districtName']
|
|
?? $payload['district_name']
|
|
?? ($payload['gateway_abha_profile']['district_name'] ?? '')
|
|
?? ($gatewayPatientPayload['district'] ?? '')
|
|
?? ''
|
|
));
|
|
$verifiedStatus = trim((string) (
|
|
($payload['gateway_abha_profile']['status'] ?? '')
|
|
?: ($profile['verifiedStatus'] ?? '')
|
|
?: ($profile['status'] ?? '')
|
|
?: ($profile['abhaStatus'] ?? '')
|
|
?: ($payload['verifiedStatus'] ?? '')
|
|
?: ($payload['status'] ?? '')
|
|
?: ''
|
|
));
|
|
$verificationType = trim((string) (
|
|
($payload['gateway_abha_profile']['abha_type'] ?? '')
|
|
?: ($profile['verificationType'] ?? '')
|
|
?: ($profile['abhaType'] ?? '')
|
|
?: ($payload['verificationType'] ?? '')
|
|
?: ($payload['abhaType'] ?? '')
|
|
?: ''
|
|
));
|
|
$kycVerified = $profile['kycVerified'] ?? $payload['kycVerified'] ?? null;
|
|
if ($kycVerified === null) {
|
|
$kycVerified = strtolower(trim((string) ($payload['gateway_abha_profile']['status'] ?? ''))) === 'verified' ? 1 : 0;
|
|
}
|
|
$mobileVerified = $profile['mobileVerified']
|
|
?? $payload['mobileVerified']
|
|
?? ($payload['gateway_abha_profile']['mobile_verified'] ?? null);
|
|
$gatewayCity = trim((string) ($profile['city'] ?? $payload['city'] ?? ($gatewayPatientPayload['city'] ?? '')));
|
|
if ($gatewayCity === '') {
|
|
$gatewayCity = $this->extractCityFromAddress($gatewayAddress, $gatewayDistrictName, $gatewayStateName);
|
|
}
|
|
|
|
$this->upsertAbdmLocationMasters($gatewayStateCode, $gatewayStateName, $gatewayDistrictCode, $gatewayDistrictName);
|
|
|
|
$patient = $this->findPatientForAbhaProfile($requestedPid, $abhaDigits, $gatewayMobile);
|
|
$patientId = (int) ($patient['id'] ?? 0);
|
|
$before = $patient !== null ? $this->buildPatientSnapshot($patient) : null;
|
|
$after = $before;
|
|
|
|
$result['gateway_user'] = [
|
|
'name' => $gatewayName,
|
|
'mobile' => $gatewayMobile,
|
|
'dob' => $gatewayDob,
|
|
'gender' => $gatewayGender,
|
|
'address' => $gatewayAddress,
|
|
'city' => $gatewayCity,
|
|
'zip' => $gatewayZip,
|
|
'state_code' => $gatewayStateCode,
|
|
'state_name' => $gatewayStateName,
|
|
'district_code' => $gatewayDistrictCode,
|
|
'district_name' => $gatewayDistrictName,
|
|
'abha_number' => $abhaDigits,
|
|
'abha_address' => $abhaAddress,
|
|
'photo_base64' => $gatewayPhoto,
|
|
];
|
|
|
|
$result['hms_patient'] = [
|
|
'requested_patient_id' => $requestedPid > 0 ? $requestedPid : null,
|
|
'matched_patient_id' => $patientId > 0 ? $patientId : null,
|
|
'before' => $before,
|
|
'after' => $after,
|
|
'comparison' => [
|
|
'name_match_before' => $before !== null
|
|
? mb_strtolower(trim((string) ($before['name'] ?? ''))) === mb_strtolower($gatewayName)
|
|
: null,
|
|
'name_match_after' => $after !== null
|
|
? mb_strtolower(trim((string) ($after['name'] ?? ''))) === mb_strtolower($gatewayName)
|
|
: null,
|
|
'mobile_match_before' => $before !== null
|
|
? preg_replace('/\D/', '', (string) ($before['mobile'] ?? '')) === preg_replace('/\D/', '', $gatewayMobile)
|
|
: null,
|
|
'mobile_match_after' => $after !== null
|
|
? preg_replace('/\D/', '', (string) ($after['mobile'] ?? '')) === preg_replace('/\D/', '', $gatewayMobile)
|
|
: null,
|
|
'abha_match_before' => $before !== null
|
|
? preg_replace('/\D/', '', (string) ($before['abha_id'] ?? '')) === $abhaDigits
|
|
: null,
|
|
'abha_match_after' => $after !== null
|
|
? preg_replace('/\D/', '', (string) ($after['abha_id'] ?? '')) === $abhaDigits
|
|
: null,
|
|
'dob_match_before' => $before !== null
|
|
? trim((string) ($before['dob'] ?? '')) === trim($gatewayDob)
|
|
: null,
|
|
'dob_match_after' => $after !== null
|
|
? trim((string) ($after['dob'] ?? '')) === trim($gatewayDob)
|
|
: null,
|
|
'gender_match_before' => $before !== null
|
|
? $this->normalizeGenderForCompare((string) ($before['gender'] ?? '')) === $this->normalizeGenderForCompare($gatewayGender)
|
|
: null,
|
|
'gender_match_after' => $after !== null
|
|
? $this->normalizeGenderForCompare((string) ($after['gender'] ?? '')) === $this->normalizeGenderForCompare($gatewayGender)
|
|
: null,
|
|
],
|
|
'photo_update' => [
|
|
'saved' => false,
|
|
'path' => '',
|
|
'error' => '',
|
|
],
|
|
];
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Return only safe/required fields for ABHA OTP verification APIs.
|
|
* Prevents leaking connector tokens, full bridge profile dumps, and base64 photo blobs.
|
|
*
|
|
* @param array<string, mixed> $result
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sanitizeAbhaVerifyApiResponse(array $result): array
|
|
{
|
|
$ok = ! empty($result['ok']) && (int) $result['ok'] === 1;
|
|
if (! $ok) {
|
|
return [
|
|
'ok' => 0,
|
|
'error_text' => (string) ($result['error_text'] ?? $result['message'] ?? $result['data']['message'] ?? 'OTP verification failed'),
|
|
'request_id' => (string) ($result['request_id'] ?? ''),
|
|
];
|
|
}
|
|
|
|
$payload = [];
|
|
if (isset($result['data']) && is_array($result['data'])) {
|
|
$payload = $result['data'];
|
|
} elseif (is_array($result)) {
|
|
$payload = $result;
|
|
}
|
|
|
|
$profile = $this->pickGatewayAbhaProfile($payload);
|
|
|
|
$txnId = (string) ($payload['txnId'] ?? $payload['txn_id'] ?? $result['txnId'] ?? $result['txn_id'] ?? '');
|
|
$abhaRaw = (string) ($payload['ABHANumber'] ?? $payload['abha_number'] ?? $payload['abha_id'] ?? $profile['ABHANumber'] ?? $profile['abha_number'] ?? $profile['abha_id'] ?? '');
|
|
$abhaDigits = preg_replace('/\D/', '', $abhaRaw);
|
|
$abhaAddress = (string) ($payload['preferredAbhaAddress'] ?? $payload['abha_address'] ?? $profile['preferredAbhaAddress'] ?? $profile['abha_address'] ?? $profile['preferredAddress'] ?? '');
|
|
$name = trim((string) (
|
|
$payload['name']
|
|
?? $payload['full_name']
|
|
?? $profile['name']
|
|
?? $profile['fullName']
|
|
?? $profile['full_name']
|
|
?? trim(implode(' ', array_filter([
|
|
(string) ($profile['firstName'] ?? ''),
|
|
(string) ($profile['middleName'] ?? ''),
|
|
(string) ($profile['lastName'] ?? ''),
|
|
])))
|
|
));
|
|
$mobile = (string) ($payload['mobile'] ?? $payload['mobileNumber'] ?? $profile['mobile'] ?? '');
|
|
$gender = (string) ($payload['gender'] ?? $profile['gender'] ?? '');
|
|
$dob = (string) ($payload['dob'] ?? $payload['date_of_birth'] ?? $profile['dob'] ?? $profile['date_of_birth'] ?? '');
|
|
$address = (string) ($payload['address'] ?? $payload['address_line'] ?? $profile['address'] ?? $profile['address_line'] ?? '');
|
|
$pinCode = (string) ($payload['pinCode'] ?? $payload['pin_code'] ?? $payload['pincode'] ?? $profile['pinCode'] ?? $profile['pin_code'] ?? '');
|
|
$stateCode = (string) ($payload['stateCode'] ?? $payload['state_code'] ?? $profile['stateCode'] ?? $profile['state_code'] ?? '');
|
|
$stateName = (string) ($payload['stateName'] ?? $payload['state_name'] ?? $profile['stateName'] ?? $profile['state_name'] ?? '');
|
|
$districtCode = (string) ($payload['districtCode'] ?? $payload['district_code'] ?? $profile['districtCode'] ?? $profile['district_code'] ?? '');
|
|
$districtName = (string) ($payload['districtName'] ?? $payload['district_name'] ?? $profile['districtName'] ?? $profile['district_name'] ?? '');
|
|
|
|
$matchedPatientId = (int) ($result['hms_patient']['matched_patient_id'] ?? 0);
|
|
$patientId = (int) ($result['patient_id'] ?? $payload['patient_id'] ?? $matchedPatientId);
|
|
$pCode = (string) ($result['p_code'] ?? $payload['p_code'] ?? '');
|
|
$isNew = $result['is_new_patient'] ?? $payload['is_new_patient'] ?? null;
|
|
|
|
$safeProfile = [
|
|
'ABHANumber' => $abhaDigits,
|
|
'abha_number' => $abhaDigits,
|
|
'abha_id' => $abhaDigits,
|
|
'preferredAbhaAddress' => $abhaAddress,
|
|
'preferredAddress' => $abhaAddress,
|
|
'name' => $name,
|
|
'full_name' => $name,
|
|
'gender' => $gender,
|
|
'dob' => $dob,
|
|
'mobile' => $mobile,
|
|
'address' => $address,
|
|
'pinCode' => $pinCode,
|
|
'stateCode' => $stateCode,
|
|
'stateName' => $stateName,
|
|
'districtCode' => $districtCode,
|
|
'districtName' => $districtName,
|
|
'firstName' => (string) ($profile['firstName'] ?? ''),
|
|
'middleName' => (string) ($profile['middleName'] ?? ''),
|
|
'lastName' => (string) ($profile['lastName'] ?? ''),
|
|
];
|
|
|
|
return [
|
|
'ok' => 1,
|
|
'request_id' => (string) ($result['request_id'] ?? ''),
|
|
'message' => (string) ($payload['message'] ?? $result['message'] ?? 'ABHA OTP verified successfully'),
|
|
'txn_id' => $txnId,
|
|
'abha_number' => $abhaDigits,
|
|
'abha_address' => $abhaAddress,
|
|
'name' => $name,
|
|
'mobile' => $mobile,
|
|
'gender' => $gender,
|
|
'dob' => $dob,
|
|
'address' => $address,
|
|
'pin_code' => $pinCode,
|
|
'state_code' => $stateCode,
|
|
'state_name' => $stateName,
|
|
'district_code' => $districtCode,
|
|
'district_name' => $districtName,
|
|
'patient_id' => $patientId > 0 ? $patientId : null,
|
|
'p_code' => $pCode !== '' ? $pCode : null,
|
|
'is_new_patient' => $isNew,
|
|
'data' => [
|
|
'txnId' => $txnId,
|
|
'abha_number' => $abhaDigits,
|
|
'name' => $name,
|
|
'mobile' => $mobile,
|
|
'gender' => $gender,
|
|
'dob' => $dob,
|
|
'address' => $address,
|
|
'pin_code' => $pinCode,
|
|
'state_code' => $stateCode,
|
|
'state_name' => $stateName,
|
|
'district_code' => $districtCode,
|
|
'district_name' => $districtName,
|
|
'profile' => $safeProfile,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function extractCityFromAddress(string $address, string $districtName = '', string $stateName = ''): string
|
|
{
|
|
$address = trim($address);
|
|
if ($address === '') {
|
|
return '';
|
|
}
|
|
|
|
$districtNorm = mb_strtoupper(trim($districtName));
|
|
$stateNorm = mb_strtoupper(trim($stateName));
|
|
$parts = array_values(array_filter(array_map('trim', explode(',', $address)), static fn (string $v): bool => $v !== ''));
|
|
if ($parts === []) {
|
|
return '';
|
|
}
|
|
|
|
for ($i = count($parts) - 1; $i >= 0; $i--) {
|
|
$candidate = trim((string) ($parts[$i] ?? ''));
|
|
if ($candidate === '' || preg_match('/\d/', $candidate) === 1) {
|
|
continue;
|
|
}
|
|
|
|
$norm = mb_strtoupper($candidate);
|
|
if (($districtNorm !== '' && $norm === $districtNorm) || ($stateNorm !== '' && $norm === $stateNorm)) {
|
|
continue;
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function upsertAbdmLocationMasters(string $stateCode, string $stateName, string $districtCode, string $districtName): void
|
|
{
|
|
// State data lives in india_state (read-only reference — no writes needed from ABHA flow).
|
|
|
|
if ($districtCode !== '' && $districtName !== '' && $this->db->tableExists('abdm_district_master')) {
|
|
$districtBuilder = $this->db->table('abdm_district_master')
|
|
->select('id')
|
|
->where('district_code', $districtCode);
|
|
if ($stateCode !== '') {
|
|
$districtBuilder->where('state_code', $stateCode);
|
|
}
|
|
$districtRow = $districtBuilder->limit(1)->get()->getRowArray();
|
|
|
|
if ($districtRow === null) {
|
|
$this->db->table('abdm_district_master')->insert([
|
|
'district_code' => $districtCode,
|
|
'district_name' => $districtName,
|
|
'state_code' => $stateCode,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
} else {
|
|
$this->db->table('abdm_district_master')
|
|
->where('id', (int) ($districtRow['id'] ?? 0))
|
|
->update([
|
|
'district_name' => $districtName,
|
|
'state_code' => $stateCode,
|
|
'updated_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function pickGatewayAbhaProfile(array $payload): array
|
|
{
|
|
$candidates = [
|
|
$payload['ABHAProfile'] ?? null,
|
|
$payload['gateway_abha_profile'] ?? null,
|
|
$payload['profile'] ?? null,
|
|
$payload['accounts'][0] ?? null,
|
|
$payload['gateway_patient'] ?? null,
|
|
$payload,
|
|
];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if (is_array($candidate) && $this->looksLikeGatewayAbhaProfile($candidate)) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $profile
|
|
*/
|
|
private function looksLikeGatewayAbhaProfile(array $profile): bool
|
|
{
|
|
foreach (['ABHANumber', 'abha_id', 'preferredAbhaAddress', 'abha_address', 'name', 'fullName', 'full_name', 'gender', 'dob', 'date_of_birth', 'profilePhoto', 'profile_photo'] as $key) {
|
|
if (array_key_exists($key, $profile) && trim((string) $profile[$key]) !== '') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function findPatientForAbhaProfile(int $requestedPid, string $abhaDigits, string $mobile): ?array
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return null;
|
|
}
|
|
|
|
if ($requestedPid > 0) {
|
|
$row = $this->db->table('patient_master')->where('id', $requestedPid)->get()->getRowArray();
|
|
if ($row !== null) {
|
|
return $row;
|
|
}
|
|
}
|
|
|
|
$abhaField = $this->resolvePatientAbhaIdField();
|
|
if ($abhaDigits !== '' && $abhaField !== null) {
|
|
$row = $this->db->table('patient_master')->where($abhaField, $abhaDigits)->get()->getRowArray();
|
|
if ($row !== null) {
|
|
return $row;
|
|
}
|
|
}
|
|
|
|
$mobileDigits = preg_replace('/\D/', '', $mobile);
|
|
if ($mobileDigits !== '') {
|
|
$row = $this->db->table('patient_master')->where('mphone1', $mobileDigits)->get()->getRowArray();
|
|
if ($row !== null) {
|
|
return $row;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildPatientSnapshot(array $row): array
|
|
{
|
|
$abhaField = $this->resolvePatientAbhaIdField();
|
|
$abhaId = $abhaField !== null ? trim((string) ($row[$abhaField] ?? '')) : '';
|
|
$abhaAddress = trim((string) ($row['abha_address'] ?? ''));
|
|
|
|
return [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'p_code' => trim((string) ($row['p_code'] ?? '')),
|
|
'name' => trim((string) ($row['p_fname'] ?? '')),
|
|
'mobile' => trim((string) ($row['mphone1'] ?? '')),
|
|
'dob' => trim((string) ($row['dob'] ?? '')),
|
|
'gender' => trim((string) ($row['gender'] ?? '')),
|
|
'abha_id' => $abhaId,
|
|
'abha_address' => $abhaAddress,
|
|
'profile_picture' => trim((string) ($row['profile_picture'] ?? '')),
|
|
'profile_file_id' => (int) ($row['profile_file_id'] ?? 0),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* ABDM verify-otp/eKYC responses return DOB as "DD-MM-YYYY". Convert to
|
|
* MySQL "YYYY-MM-DD" for storage. Returns '' if unparseable/empty.
|
|
*/
|
|
private function normalizeGatewayDobToDb(string $dob): string
|
|
{
|
|
$dob = trim($dob);
|
|
if ($dob === '') {
|
|
return '';
|
|
}
|
|
|
|
if (preg_match('/^(\d{2})-(\d{2})-(\d{4})$/', $dob, $m)) {
|
|
return $m[3] . '-' . $m[2] . '-' . $m[1];
|
|
}
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dob)) {
|
|
return $dob;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Map ABDM gateway gender string (M/F/O, MALE/FEMALE/OTHER, or already 1/2/3)
|
|
* to patient_master's numeric gender code. Returns null if unrecognised.
|
|
*/
|
|
private function toPatientGenderDbValue(string $gender): ?int
|
|
{
|
|
$g = strtoupper(trim($gender));
|
|
if ($g === '') {
|
|
return null;
|
|
}
|
|
if ($g === 'M' || $g === '1' || $g === 'MALE') {
|
|
return 1;
|
|
}
|
|
if ($g === 'F' || $g === '2' || $g === 'FEMALE') {
|
|
return 2;
|
|
}
|
|
if ($g === 'O' || $g === '3' || $g === 'OTHER') {
|
|
return 3;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array{saved:bool,path:string,error:string}
|
|
*/
|
|
private function saveGatewayProfilePhotoToPatient(int $pid, string $photoBase64): array
|
|
{
|
|
$raw = trim($photoBase64);
|
|
if ($pid <= 0 || $raw === '') {
|
|
return ['saved' => false, 'path' => '', 'error' => 'Missing patient id or photo.'];
|
|
}
|
|
|
|
$mime = 'image/jpeg';
|
|
$encoded = $raw;
|
|
if (str_starts_with($raw, 'data:image')) {
|
|
$parts = explode('base64,', $raw, 2);
|
|
if (count($parts) !== 2) {
|
|
return ['saved' => false, 'path' => '', 'error' => 'Invalid image data URI.'];
|
|
}
|
|
if (preg_match('/data:(image\/[a-zA-Z0-9.+-]+);/i', $parts[0], $m) === 1) {
|
|
$mime = strtolower((string) ($m[1] ?? 'image/jpeg'));
|
|
}
|
|
$encoded = $parts[1];
|
|
}
|
|
|
|
$binary = base64_decode($encoded, true);
|
|
if ($binary === false) {
|
|
return ['saved' => false, 'path' => '', 'error' => 'Unable to decode photo base64.'];
|
|
}
|
|
|
|
$ext = match ($mime) {
|
|
'image/png' => 'png',
|
|
'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
default => 'jpg',
|
|
};
|
|
|
|
$uploadPath = rtrim(FCPATH, '\\/') . '/uploads/patient';
|
|
if (! is_dir($uploadPath) && ! mkdir($uploadPath, 0755, true) && ! is_dir($uploadPath)) {
|
|
return ['saved' => false, 'path' => '', 'error' => 'Unable to create upload directory.'];
|
|
}
|
|
|
|
$fileName = 'abha_profile_' . $pid . '_' . time() . '.' . $ext;
|
|
$absolutePath = $uploadPath . '/' . $fileName;
|
|
if (file_put_contents($absolutePath, $binary) === false) {
|
|
return ['saved' => false, 'path' => '', 'error' => 'Unable to write photo file.'];
|
|
}
|
|
|
|
$publicPath = '/uploads/patient/' . $fileName;
|
|
$insertId = $this->insertFileUploadRecordFromData($pid, 'profile', $publicPath, $mime, strlen($binary));
|
|
|
|
$updates = [];
|
|
if ($insertId > 0) {
|
|
$updates['profile_file_id'] = $insertId;
|
|
}
|
|
if ($this->db->fieldExists('profile_picture', 'patient_master')) {
|
|
$updates['profile_picture'] = $publicPath;
|
|
}
|
|
|
|
if ($updates !== []) {
|
|
$this->db->table('patient_master')->where('id', $pid)->update($updates);
|
|
}
|
|
|
|
return ['saved' => true, 'path' => $publicPath, 'error' => ''];
|
|
}
|
|
|
|
private function normalizeGenderForCompare(string $value): string
|
|
{
|
|
$v = strtoupper(trim($value));
|
|
if ($v === '1' || $v === 'M' || $v === 'MALE') {
|
|
return 'M';
|
|
}
|
|
if ($v === '2' || $v === 'F' || $v === 'FEMALE') {
|
|
return 'F';
|
|
}
|
|
if ($v === '3' || $v === 'O' || $v === 'OTHER') {
|
|
return 'O';
|
|
}
|
|
|
|
return $v;
|
|
}
|
|
|
|
private function parseDate(?string $date): ?string
|
|
{
|
|
if ($date === null || trim($date) === '') {
|
|
return null;
|
|
}
|
|
|
|
$date = trim($date);
|
|
|
|
$dt = \DateTime::createFromFormat('Y-m-d', $date);
|
|
if (!$dt) {
|
|
$dt = \DateTime::createFromFormat('d/m/Y', $date);
|
|
}
|
|
|
|
return $dt ? $dt->format('Y-m-d') : null;
|
|
}
|
|
|
|
private function saveNamesToNameList(array $names): void
|
|
{
|
|
if (! $this->db->tableExists('name_list')) {
|
|
return;
|
|
}
|
|
|
|
$builder = $this->db->table('name_list');
|
|
foreach ($names as $rawName) {
|
|
$name = preg_replace('/\s+/', ' ', trim((string) $rawName));
|
|
if ($name === '' || strlen($name) < 2) {
|
|
continue;
|
|
}
|
|
|
|
$name = ucwords(strtolower($name));
|
|
$exists = $builder->select('id')->where('name', $name)->limit(1)->get()->getRowArray();
|
|
if ($exists) {
|
|
continue;
|
|
}
|
|
|
|
$builder->insert(['name' => $name]);
|
|
}
|
|
}
|
|
|
|
private function applyPatientAbhaFieldValues(array &$data, string $abhaId, string $abhaAddress = ''): void
|
|
{
|
|
$fields = $this->db->tableExists('patient_master') ? ($this->db->getFieldNames('patient_master') ?? []) : [];
|
|
|
|
if (in_array('abha_id', $fields, true)) {
|
|
$data['abha_id'] = $abhaId;
|
|
} else {
|
|
$targetField = $this->resolvePatientAbhaIdField();
|
|
if ($targetField !== null) {
|
|
$data[$targetField] = $abhaId;
|
|
}
|
|
}
|
|
|
|
if (in_array('abha_address', $fields, true) && $abhaAddress !== '') {
|
|
$data['abha_address'] = $abhaAddress;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{0:string,1:string,2:?string}
|
|
*/
|
|
private function normalizeAbhaInputs(string $abhaIdInput, string $abhaAddressInput): array
|
|
{
|
|
$abhaId = trim($abhaIdInput);
|
|
$abhaAddress = trim($abhaAddressInput);
|
|
|
|
if ($abhaAddress === '' && $this->isLikelyAbhaAddress($abhaId)) {
|
|
$abhaAddress = $abhaId;
|
|
$abhaId = '';
|
|
}
|
|
|
|
if ($abhaId === '' && $abhaAddress !== '' && $this->isValidAbhaId($abhaAddress)) {
|
|
$abhaId = $abhaAddress;
|
|
$abhaAddress = '';
|
|
}
|
|
|
|
if ($abhaId !== '' && ! $this->isValidAbhaId($abhaId)) {
|
|
return ['', '', 'ABHA ID must be a 14-digit number.'];
|
|
}
|
|
|
|
if ($abhaAddress !== '' && ! $this->isLikelyAbhaAddress($abhaAddress)) {
|
|
return ['', '', 'ABHA Address format looks invalid.'];
|
|
}
|
|
|
|
return [$abhaId, $abhaAddress, null];
|
|
}
|
|
|
|
private function isLikelyAbhaAddress(string $value): bool
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return false;
|
|
}
|
|
|
|
return preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{1,}@[A-Za-z0-9.-]+$/', $value) === 1;
|
|
}
|
|
|
|
private function resolvePatientAbhaIdField(): ?string
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return null;
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('patient_master') ?? [];
|
|
foreach (['abha_id', 'abha_no', 'abha'] as $field) {
|
|
if (in_array($field, $fields, true)) {
|
|
return $field;
|
|
}
|
|
}
|
|
|
|
if (in_array('abha_address', $fields, true)) {
|
|
return 'abha_address';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function isValidAbhaId(string $value): bool
|
|
{
|
|
return preg_match('/^\d{14}$/', $value) === 1;
|
|
}
|
|
|
|
/**
|
|
* Find an existing patient in patient_master by ABHA ID (14 digits) or ABHA Address.
|
|
* Matches raw digits, formatted (XX-XXXX-XXXX-XXXX), or ABHA address across all existing ABHA columns.
|
|
*
|
|
* @return array{id:int,p_code:string,p_fname:string,mphone1:string,abha_id:string,udai:string}|null
|
|
*/
|
|
public function findPatientByAbha(string $abha, ?int $excludePatientId = null): ?array
|
|
{
|
|
$abha = trim($abha);
|
|
if ($abha === '') {
|
|
return null;
|
|
}
|
|
|
|
$db = $this->db ?? \Config\Database::connect();
|
|
if (! $db->tableExists('patient_master')) {
|
|
return null;
|
|
}
|
|
|
|
$fields = $db->getFieldNames('patient_master') ?? [];
|
|
$digits = preg_replace('/\D/', '', $abha) ?? '';
|
|
$is14Digits = strlen($digits) === 14;
|
|
$formatted14 = $is14Digits
|
|
? substr($digits, 0, 2) . '-' . substr($digits, 2, 4) . '-' . substr($digits, 6, 4) . '-' . substr($digits, 10, 4)
|
|
: '';
|
|
|
|
$builder = $db->table('patient_master');
|
|
$builder->select('id,p_code,p_fname,mphone1');
|
|
|
|
$selectAbhaField = null;
|
|
foreach (['abha_id', 'abha_no', 'abha'] as $f) {
|
|
if (in_array($f, $fields, true)) {
|
|
$selectAbhaField = $f;
|
|
break;
|
|
}
|
|
}
|
|
if ($selectAbhaField !== null) {
|
|
$builder->select($selectAbhaField . ' AS abha_id');
|
|
}
|
|
if (in_array('udai', $fields, true)) {
|
|
$builder->select('udai');
|
|
}
|
|
|
|
$matchedAnyCondition = false;
|
|
$builder->groupStart();
|
|
|
|
// Check all candidate numeric ABHA columns
|
|
foreach (['abha_id', 'abha_no', 'abha'] as $f) {
|
|
if (in_array($f, $fields, true)) {
|
|
if ($is14Digits) {
|
|
if (! $matchedAnyCondition) {
|
|
$builder->whereIn($f, [$digits, $formatted14]);
|
|
$matchedAnyCondition = true;
|
|
} else {
|
|
$builder->orWhereIn($f, [$digits, $formatted14]);
|
|
}
|
|
} else {
|
|
if (! $matchedAnyCondition) {
|
|
$builder->where($f, $abha);
|
|
$matchedAnyCondition = true;
|
|
} else {
|
|
$builder->orWhere($f, $abha);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check abha_address column if ABHA address provided (e.g. name@abdm)
|
|
if (in_array('abha_address', $fields, true) && strpos($abha, '@') !== false) {
|
|
if (! $matchedAnyCondition) {
|
|
$builder->where('abha_address', $abha);
|
|
$matchedAnyCondition = true;
|
|
} else {
|
|
$builder->orWhere('abha_address', $abha);
|
|
}
|
|
}
|
|
|
|
$builder->groupEnd();
|
|
|
|
if (! $matchedAnyCondition) {
|
|
return null;
|
|
}
|
|
|
|
if ($excludePatientId !== null && $excludePatientId > 0) {
|
|
$builder->where('id !=', $excludePatientId);
|
|
}
|
|
|
|
$row = $builder->orderBy('id', 'ASC')->get(1)->getRowArray();
|
|
if (! $row) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'p_code' => (string) ($row['p_code'] ?? ''),
|
|
'p_fname' => (string) ($row['p_fname'] ?? ''),
|
|
'mphone1' => (string) ($row['mphone1'] ?? ''),
|
|
'abha_id' => (string) ($row['abha_id'] ?? ''),
|
|
'udai' => (string) ($row['udai'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Attempt to resolve known ABHA verified profile data (from session or bridge)
|
|
* for demographic validation during linking.
|
|
*/
|
|
public function resolveKnownAbhaProfile(string $abhaId = '', string $abhaAddress = ''): ?array
|
|
{
|
|
$cleanAbha = preg_replace('/\D/', '', $abhaId);
|
|
$cleanAddr = trim($abhaAddress);
|
|
|
|
// 1. Session check
|
|
$sessProfile = session()->get('abha_verified_profile') ?? session()->get('abha_profile');
|
|
if (is_array($sessProfile)) {
|
|
$sessAddr = trim((string) ($sessProfile['abha_address'] ?? ''));
|
|
$sessId = preg_replace('/\D/', '', (string) ($sessProfile['abha_number'] ?? $sessProfile['abha_id'] ?? ''));
|
|
if (($cleanAddr !== '' && $sessAddr === $cleanAddr) || ($cleanAbha !== '' && $sessId === $cleanAbha)) {
|
|
return $sessProfile;
|
|
}
|
|
}
|
|
|
|
// 2. Query bridge abdm_gateway_db.abdm_abha_profiles if available
|
|
try {
|
|
$bridgeDb = \Config\Database::connect('abdm_gateway_db', false);
|
|
if ($bridgeDb && $bridgeDb->tableExists('abdm_abha_profiles')) {
|
|
$b = $bridgeDb->table('abdm_abha_profiles');
|
|
if ($cleanAddr !== '' && $cleanAbha !== '') {
|
|
$b->groupStart()->where('abha_address', $cleanAddr)->orWhere('abha_number LIKE', "%{$cleanAbha}%")->groupEnd();
|
|
} elseif ($cleanAddr !== '') {
|
|
$b->where('abha_address', $cleanAddr);
|
|
} elseif ($cleanAbha !== '') {
|
|
$b->where('abha_number LIKE', "%{$cleanAbha}%");
|
|
}
|
|
$row = $b->orderBy('id', 'DESC')->get(1)->getRowArray();
|
|
if (! empty($row)) {
|
|
return [
|
|
'name' => (string) ($row['full_name'] ?? $row['name'] ?? ''),
|
|
'gender' => (string) ($row['gender'] ?? ''),
|
|
'date_of_birth' => (string) ($row['date_of_birth'] ?? ''),
|
|
'year_of_birth' => (string) ($row['year_of_birth'] ?? ''),
|
|
'mobile' => (string) ($row['mobile'] ?? ''),
|
|
'abha_address' => (string) ($row['abha_address'] ?? ''),
|
|
'abha_number' => (string) ($row['abha_number'] ?? ''),
|
|
];
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Secondary DB connection not configured, fallback to null
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find an existing patient in patient_master by Aadhaar number (12 digits).
|
|
* Matches deterministic hash in udai_hash and unmasked digits in udai.
|
|
*
|
|
* @return array{id:int,p_code:string,p_fname:string,mphone1:string,abha_id:string,udai:string}|null
|
|
*/
|
|
public function findPatientByAadhaar(string $aadhaar, ?int $excludePatientId = null): ?array
|
|
{
|
|
$aadhaar = trim($aadhaar);
|
|
if ($aadhaar === '') {
|
|
return null;
|
|
}
|
|
|
|
$vault = new \App\Libraries\AadhaarVaultService();
|
|
if ($vault->isMasked($aadhaar)) {
|
|
return null;
|
|
}
|
|
|
|
$digits = $vault->normalize($aadhaar);
|
|
if ($digits === '') {
|
|
return null;
|
|
}
|
|
|
|
$db = $this->db ?? \Config\Database::connect();
|
|
if (! $db->tableExists('patient_master')) {
|
|
return null;
|
|
}
|
|
|
|
$fields = $db->getFieldNames('patient_master') ?? [];
|
|
$hash = $vault->hash($digits);
|
|
$formattedWithSpaces = substr($digits, 0, 4) . ' ' . substr($digits, 4, 4) . ' ' . substr($digits, 8, 4);
|
|
|
|
$builder = $db->table('patient_master');
|
|
$builder->select('id,p_code,p_fname,mphone1');
|
|
|
|
$selectAbhaField = null;
|
|
foreach (['abha_id', 'abha_no', 'abha'] as $f) {
|
|
if (in_array($f, $fields, true)) {
|
|
$selectAbhaField = $f;
|
|
break;
|
|
}
|
|
}
|
|
if ($selectAbhaField !== null) {
|
|
$builder->select($selectAbhaField . ' AS abha_id');
|
|
}
|
|
if (in_array('udai', $fields, true)) {
|
|
$builder->select('udai');
|
|
}
|
|
|
|
$hasHashField = in_array('udai_hash', $fields, true);
|
|
$hasUdaiField = in_array('udai', $fields, true);
|
|
|
|
if (! $hasHashField && ! $hasUdaiField) {
|
|
return null;
|
|
}
|
|
|
|
$builder->groupStart();
|
|
$hasCond = false;
|
|
|
|
if ($hasHashField && $hash !== '') {
|
|
$builder->where('udai_hash', $hash);
|
|
$hasCond = true;
|
|
}
|
|
|
|
if ($hasUdaiField) {
|
|
if (! $hasCond) {
|
|
$builder->whereIn('udai', [$digits, $formattedWithSpaces]);
|
|
$hasCond = true;
|
|
} else {
|
|
$builder->orWhereIn('udai', [$digits, $formattedWithSpaces]);
|
|
}
|
|
}
|
|
|
|
$builder->groupEnd();
|
|
|
|
if ($excludePatientId !== null && $excludePatientId > 0) {
|
|
$builder->where('id !=', $excludePatientId);
|
|
}
|
|
|
|
$row = $builder->orderBy('id', 'ASC')->get(1)->getRowArray();
|
|
if (! $row) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'p_code' => (string) ($row['p_code'] ?? ''),
|
|
'p_fname' => (string) ($row['p_fname'] ?? ''),
|
|
'mphone1' => (string) ($row['mphone1'] ?? ''),
|
|
'abha_id' => (string) ($row['abha_id'] ?? ''),
|
|
'udai' => (string) ($row['udai'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $patientData
|
|
*/
|
|
private function enqueuePatientAbhaSync(int $patientId, array $patientData, string $eventType): void
|
|
{
|
|
if ($patientId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$abhaField = $this->resolvePatientAbhaIdField();
|
|
$abhaId = $abhaField !== null ? trim((string) ($patientData[$abhaField] ?? '')) : '';
|
|
|
|
$payload = [
|
|
'patient_id' => $patientId,
|
|
'abha_id' => $abhaId,
|
|
'name' => trim((string) ($patientData['p_fname'] ?? '')),
|
|
'mobile' => trim((string) ($patientData['mphone1'] ?? '')),
|
|
'gender' => trim((string) ($patientData['gender'] ?? '')),
|
|
'dob' => trim((string) ($patientData['dob'] ?? '')),
|
|
'city' => trim((string) ($patientData['city'] ?? '')),
|
|
'state' => trim((string) ($patientData['state'] ?? '')),
|
|
];
|
|
|
|
try {
|
|
$bridgeSync = new BridgeSyncService();
|
|
$bridgeSync->enqueue(
|
|
$eventType,
|
|
$payload,
|
|
'patient',
|
|
(string) $patientId
|
|
);
|
|
} catch (\Throwable $e) {
|
|
// Do not block patient workflows if queue service is unavailable.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously queue ABDM deep-link SMS notify (sms/notify2) when patient registers with phone only.
|
|
* Handled via background Spark cron worker so internet downtime never blocks patient creation.
|
|
*
|
|
* @param array<string, mixed> $patientData
|
|
*/
|
|
private function enqueuePatientSmsNotify(int $patientId, array $patientData, string $trigger = 'patient.created'): void
|
|
{
|
|
if ($patientId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$phone = preg_replace('/\D/', '', (string) ($patientData['mphone1'] ?? ''));
|
|
if (strlen($phone) < 10) {
|
|
return;
|
|
}
|
|
|
|
$abhaField = $this->resolvePatientAbhaIdField();
|
|
$abhaId = $abhaField !== null ? trim((string) ($patientData[$abhaField] ?? '')) : '';
|
|
$abhaAddress = trim((string) ($patientData['abha_address'] ?? ''));
|
|
|
|
// Only queue for patients who do not have established ABHA details
|
|
if ($abhaId !== '' || $abhaAddress !== '') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$smsService = new \App\Libraries\Abdm\Sync\AbdmSmsNotifyService($this->db);
|
|
$smsService->enqueue($patientId, substr($phone, -10), '', $trigger);
|
|
} catch (\Throwable $e) {
|
|
// Fail-open: do not block patient workflows
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $patientData
|
|
*/
|
|
private function createPatientAbdmWorkTask(int $patientId, array $patientData, string $abhaId): void
|
|
{
|
|
if ($patientId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$taskService = new AbdmWorkTaskService();
|
|
$patientName = trim((string) ($patientData['p_fname'] ?? ''));
|
|
$actionMode = $abhaId !== '' ? 'update_abha' : 'create_abha';
|
|
$taskType = $abhaId !== '' ? 'patient_abha_update' : 'patient_abha_create';
|
|
|
|
$taskService->createOrRefreshTask(
|
|
$taskType,
|
|
'patient_registration',
|
|
'patient',
|
|
(string) $patientId,
|
|
$patientId,
|
|
$patientName,
|
|
$abhaId,
|
|
$actionMode,
|
|
[
|
|
'trigger' => 'patient.created',
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildAbhaProfileContext(array $row): array
|
|
{
|
|
$abhaField = $this->resolvePatientAbhaIdField();
|
|
$abhaIdRaw = $abhaField !== null ? trim((string) ($row[$abhaField] ?? '')) : '';
|
|
$abhaAddress = trim((string) ($row['abha_address'] ?? ''));
|
|
|
|
if ($abhaAddress === '' && $abhaIdRaw !== '' && $this->isLikelyAbhaAddress($abhaIdRaw)) {
|
|
$abhaAddress = $abhaIdRaw;
|
|
}
|
|
|
|
if ($abhaAddress === '' && preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', (string) ($row['log'] ?? ''), $m) === 1) {
|
|
$abhaAddress = trim((string) ($m[1] ?? ''));
|
|
}
|
|
|
|
$verifiedStatus = strtoupper(trim((string) ($row['abha_verified_status'] ?? '')));
|
|
$kycVerified = (int) ($row['abha_kyc_verified'] ?? 0) === 1;
|
|
$mobileVerified = (int) ($row['abha_mobile_verified'] ?? 0) === 1;
|
|
$hasStatusColumn = array_key_exists('abha_verified_status', $row);
|
|
$isVerified = in_array($verifiedStatus, ['1', 'VERIFIED', 'YES', 'Y', 'TRUE'], true) || ($kycVerified && $mobileVerified);
|
|
if (! $hasStatusColumn && $abhaAddress !== '') {
|
|
$isVerified = true;
|
|
}
|
|
|
|
return [
|
|
'abha_id' => $abhaIdRaw,
|
|
'abha_address' => $abhaAddress,
|
|
'verified_status' => $verifiedStatus,
|
|
'is_verified' => $isVerified ? 1 : 0,
|
|
];
|
|
}
|
|
|
|
private function deriveAutoFlowState(array $result): string
|
|
{
|
|
$blob = strtolower(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '');
|
|
|
|
if (str_contains($blob, 'granted') || str_contains($blob, 'approved') || str_contains($blob, 'consent_granted')) {
|
|
return 'GRANTED';
|
|
}
|
|
|
|
if (str_contains($blob, 'denied') || str_contains($blob, 'revoked') || str_contains($blob, 'expired') || str_contains($blob, 'rejected')) {
|
|
return 'DENIED';
|
|
}
|
|
|
|
if (str_contains($blob, 'requested') || str_contains($blob, 'pending') || str_contains($blob, 'await')) {
|
|
return 'PENDING';
|
|
}
|
|
|
|
$explicit = strtoupper(trim((string) ($result['workflow_state'] ?? $result['state'] ?? $result['status'] ?? '')));
|
|
if ($explicit !== '') {
|
|
if (in_array($explicit, ['GRANTED', 'APPROVED', 'COMPLETED'], true)) {
|
|
return 'GRANTED';
|
|
}
|
|
if (in_array($explicit, ['REVOKED', 'EXPIRED', 'DENIED', 'REJECTED'], true)) {
|
|
return 'DENIED';
|
|
}
|
|
}
|
|
|
|
return 'REQUESTED';
|
|
}
|
|
|
|
private function findLatestAbdmGatewayRequestId(string $abhaAddress): string
|
|
{
|
|
$abhaAddress = trim($abhaAddress);
|
|
if ($abhaAddress === '' || ! $this->db->tableExists('abdm_hiu_workflows')) {
|
|
return '';
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
|
|
$select = ['request_id', 'response_json'];
|
|
if (in_array('gateway_request_id', $fields, true)) {
|
|
$select[] = 'gateway_request_id';
|
|
}
|
|
if (in_array('hms_request_id', $fields, true)) {
|
|
$select[] = 'hms_request_id';
|
|
}
|
|
|
|
$rows = $this->db->table('abdm_hiu_workflows')
|
|
->select(implode(', ', $select))
|
|
->where('operation', 'consent_request')
|
|
->where('abha_address', $abhaAddress)
|
|
->where('status', 'success')
|
|
->orderBy('id', 'DESC')
|
|
->get(20)
|
|
->getResultArray();
|
|
|
|
foreach ($rows as $row) {
|
|
$candidates = [
|
|
trim((string) ($row['gateway_request_id'] ?? '')),
|
|
trim((string) ($row['request_id'] ?? '')),
|
|
];
|
|
|
|
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
|
|
if (is_array($decoded)) {
|
|
$candidates[] = trim((string) ($decoded['gateway_request_id'] ?? ''));
|
|
$candidates[] = trim((string) ($decoded['request_id'] ?? ''));
|
|
$candidates[] = trim((string) ($decoded['requestId'] ?? ''));
|
|
}
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if ($candidate !== '' && preg_match('/^REQ-/i', $candidate) === 1) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function getLatestAbdmSyncSnapshot(string $abhaAddress): array
|
|
{
|
|
$snapshot = [
|
|
'phase' => 'IDLE',
|
|
'request_id' => '',
|
|
'consent_request_id' => '',
|
|
'consent_id' => '',
|
|
'message' => 'No previous ABDM sync activity found.',
|
|
'updated_at' => '',
|
|
'operation' => '',
|
|
'status' => '',
|
|
'restart_required' => false,
|
|
];
|
|
|
|
$abhaAddress = trim($abhaAddress);
|
|
if ($abhaAddress === '' || ! $this->db->tableExists('abdm_hiu_workflows')) {
|
|
return $snapshot;
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
|
|
// See fetchAbdmWorkflowRows() for why response_json is capped to NULL
|
|
// for data_fetch/hi_data_push_callback rows -- those can carry
|
|
// multi-MB decrypted bundles with base64 attachments that this method
|
|
// never reads fields out of anyway (only consent status/hi_types).
|
|
$responseJsonExpr = "(CASE WHEN operation IN ('data_fetch', 'hi_data_push_callback') THEN NULL ELSE response_json END) AS response_json";
|
|
$select = ['id', 'operation', 'workflow_state', 'status', 'request_id', 'consent_id', 'updated_at', 'last_error', 'http_code', $responseJsonExpr];
|
|
if (in_array('gateway_request_id', $fields, true)) {
|
|
$select[] = 'gateway_request_id';
|
|
}
|
|
if (in_array('abdm_consent_request_id', $fields, true)) {
|
|
$select[] = 'abdm_consent_request_id';
|
|
}
|
|
if (in_array('abdm_consent_artifact_id', $fields, true)) {
|
|
$select[] = 'abdm_consent_artifact_id';
|
|
}
|
|
|
|
$rows = $this->db->table('abdm_hiu_workflows')
|
|
->select(implode(', ', $select))
|
|
->where('abha_address', $abhaAddress)
|
|
->whereIn('operation', [
|
|
'consent_request',
|
|
'consent_status',
|
|
'consent_reconcile',
|
|
'data_fetch',
|
|
'consent_callback',
|
|
'hi_on_request_callback',
|
|
'hi_data_push_callback',
|
|
])
|
|
->orderBy('id', 'DESC')
|
|
->get(40)
|
|
->getResultArray();
|
|
|
|
if ($rows === []) {
|
|
return $snapshot;
|
|
}
|
|
|
|
// Scope this snapshot to the CURRENT (most recent) consent request
|
|
// "session" only. Without this, the phase-priority scan below picks
|
|
// whichever row ANYWHERE in the last 40 rows reached the highest
|
|
// lifecycle stage (e.g. an older, already-GRANTED/COMPLETED consent),
|
|
// even when the patient has since started a brand-new consent_request
|
|
// that is still just REQUESTED -- silently resurrecting a stale/
|
|
// unrelated session's consent_id and causing "Fetch Records"/live
|
|
// status checks to operate on the wrong consent (confirmed 2026-07-30:
|
|
// a new 4-facility consent request stayed masked behind a prior
|
|
// single-facility consent that had already reached GRANTED earlier).
|
|
$latestConsentRequestRow = $this->db->table('abdm_hiu_workflows')
|
|
->select('id')
|
|
->where('abha_address', $abhaAddress)
|
|
->where('operation', 'consent_request')
|
|
->orderBy('id', 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
$latestSessionStartId = (int) ($latestConsentRequestRow['id'] ?? 0);
|
|
if ($latestSessionStartId > 0) {
|
|
$rows = array_values(array_filter(
|
|
$rows,
|
|
static fn (array $row): bool => (int) ($row['id'] ?? 0) >= $latestSessionStartId
|
|
));
|
|
if ($rows === []) {
|
|
return $snapshot;
|
|
}
|
|
}
|
|
|
|
$best = null;
|
|
$bestPriority = -1;
|
|
$bestGrantedOrCompleted = null;
|
|
$bestGrantedOrCompletedId = -1;
|
|
|
|
foreach ($rows as $row) {
|
|
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
|
|
if (! is_array($decoded)) {
|
|
$decoded = [];
|
|
}
|
|
|
|
$rawConsentStatus = '';
|
|
if (isset($decoded['consent']) && is_array($decoded['consent'])) {
|
|
$rawConsentStatus = trim((string) (
|
|
$decoded['consent']['status']
|
|
?? $decoded['consent']['consent_status']
|
|
?? $decoded['consent']['consentStatus']
|
|
?? ''
|
|
));
|
|
}
|
|
if ($rawConsentStatus === '' && isset($decoded['consentDetail']) && is_array($decoded['consentDetail'])) {
|
|
$rawConsentStatus = trim((string) ($decoded['consentDetail']['status'] ?? ''));
|
|
}
|
|
if ($rawConsentStatus === '' && isset($decoded['data']['consent']) && is_array($decoded['data']['consent'])) {
|
|
$rawConsentStatus = trim((string) ($decoded['data']['consent']['status'] ?? ''));
|
|
}
|
|
if ($rawConsentStatus === '') {
|
|
$rawConsentStatus = trim((string) ($decoded['consent_status'] ?? $decoded['consentStatus'] ?? ''));
|
|
}
|
|
if ($rawConsentStatus === '' || in_array(strtolower($rawConsentStatus), ['success', 'ok', 'failed', 'error', 'status_checked'], true)) {
|
|
$topStatus = trim((string) ($decoded['status'] ?? ''));
|
|
if (! in_array(strtolower($topStatus), ['success', 'ok', 'failed', 'error', 'status_checked', '1', '0'], true)) {
|
|
$rawConsentStatus = $topStatus;
|
|
}
|
|
}
|
|
$rawConsentStatus = strtoupper($rawConsentStatus);
|
|
|
|
$requestId = trim((string) (
|
|
$row['gateway_request_id']
|
|
?? $row['request_id']
|
|
?? $decoded['gateway_request_id']
|
|
?? $decoded['request_id']
|
|
?? $decoded['requestId']
|
|
?? ''
|
|
));
|
|
$consentRequestId = trim((string) (
|
|
$row['abdm_consent_request_id']
|
|
?? $decoded['abdm_consent_request_id']
|
|
?? $decoded['consent_request_id']
|
|
?? $decoded['consentRequestId']
|
|
?? ''
|
|
));
|
|
$consentId = trim((string) (
|
|
$row['abdm_consent_artifact_id']
|
|
?? $row['consent_id']
|
|
?? $decoded['abdm_consent_artifact_id']
|
|
?? $decoded['consent_id']
|
|
?? $decoded['consentId']
|
|
?? ''
|
|
));
|
|
|
|
$operation = strtoupper(trim((string) ($row['operation'] ?? '')));
|
|
$status = strtoupper(trim((string) ($row['status'] ?? '')));
|
|
$state = strtoupper(trim((string) ($row['workflow_state'] ?? '')));
|
|
$errorText = trim((string) ($row['last_error'] ?? $decoded['error_text'] ?? ''));
|
|
$httpCode = (int) ($row['http_code'] ?? $decoded['http_code'] ?? 0);
|
|
|
|
$phase = 'REQUESTED';
|
|
$priority = 120;
|
|
|
|
// NOTE: `status` only reflects whether the bridge API call itself
|
|
// succeeded (no HTTP/transport error) -- it does NOT mean the HIP
|
|
// actually sent any health records (see M3HiuWorkflowService::
|
|
// resolveState()/runOperation(), which always set status=success for
|
|
// any error-free response, even one reporting decrypted_data=[]).
|
|
// Whether data truly arrived is tracked separately via workflow_state
|
|
// (DATA_PENDING vs DATA_RECEIVED); require that here too, otherwise a
|
|
// broad/historical data_fetch attempt with empty decrypted_data can
|
|
// wrongly hijack "latest consent" resolution with a stale consent_id.
|
|
if (($operation === 'DATA_FETCH' || $operation === 'HI_DATA_PUSH_CALLBACK') && $status === 'SUCCESS' && $state === 'DATA_RECEIVED') {
|
|
$phase = 'COMPLETED';
|
|
$priority = 500;
|
|
} elseif (in_array($rawConsentStatus, ['GRANTED', 'APPROVED', 'ACTIVE'], true)) {
|
|
$phase = 'GRANTED';
|
|
$priority = 430;
|
|
} elseif (in_array($rawConsentStatus, ['REVOKED', 'DENIED', 'EXPIRED'], true)) {
|
|
$phase = 'DENIED';
|
|
$priority = 310;
|
|
} elseif (in_array($state, ['DATA_RECEIVED'], true)) {
|
|
$phase = 'COMPLETED';
|
|
$priority = 480;
|
|
} elseif (in_array($state, ['GRANTED'], true)) {
|
|
$phase = 'GRANTED';
|
|
$priority = 420;
|
|
} elseif (in_array($state, ['REVOKED', 'EXPIRED'], true)) {
|
|
$phase = 'DENIED';
|
|
$priority = 300;
|
|
} elseif ($status === 'FAILED' && $httpCode === 404 && stripos($errorText, 'consent record not found') !== false) {
|
|
$phase = 'REQUESTED';
|
|
$priority = 200;
|
|
} elseif ($status === 'FAILED' && $operation === 'CONSENT_REQUEST') {
|
|
// Only a failed *submission* of the consent request itself is terminal.
|
|
// A failed status-check/reconcile/data-fetch attempt is transient bridge
|
|
// noise and must NOT force HMS to create a brand-new consent request
|
|
// (which would show up as a duplicate entry in the patient's PHR app).
|
|
$phase = 'FAILED';
|
|
$priority = 260;
|
|
} elseif ($status === 'FAILED') {
|
|
$phase = 'REQUESTED';
|
|
$priority = 190;
|
|
} elseif (in_array($state, ['REQUESTED', 'PENDING', 'STATUS_CHECKED'], true)) {
|
|
$phase = 'REQUESTED';
|
|
$priority = 180;
|
|
}
|
|
|
|
$message = 'Last status loaded from previous ABDM sync.';
|
|
if ($phase === 'COMPLETED') {
|
|
$message = 'Last sync completed successfully.';
|
|
} elseif ($phase === 'GRANTED') {
|
|
$message = 'Consent was granted. Continue sync to fetch records.';
|
|
} elseif ($phase === 'DENIED') {
|
|
$message = 'Consent was denied/revoked/expired in previous attempt.';
|
|
} elseif ($status === 'FAILED' && $httpCode === 404) {
|
|
$message = 'Last consent request is still being processed by bridge. Please wait and refresh.';
|
|
} elseif ($phase === 'FAILED') {
|
|
$message = 'Last consent request failed. Start a new consent request.';
|
|
}
|
|
|
|
if ($best === null || $priority > $bestPriority) {
|
|
$best = [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'phase' => $phase,
|
|
'request_id' => $requestId,
|
|
'consent_request_id' => $consentRequestId,
|
|
'consent_id' => $consentId,
|
|
'message' => $message,
|
|
'updated_at' => trim((string) ($row['updated_at'] ?? '')),
|
|
'operation' => strtolower(trim((string) ($row['operation'] ?? ''))),
|
|
'status' => strtolower(trim((string) ($row['status'] ?? ''))),
|
|
];
|
|
$bestPriority = $priority;
|
|
}
|
|
|
|
if (in_array($phase, ['GRANTED', 'COMPLETED'], true)) {
|
|
$currentId = (int) ($row['id'] ?? 0);
|
|
if ($currentId > $bestGrantedOrCompletedId) {
|
|
$bestGrantedOrCompleted = [
|
|
'id' => $currentId,
|
|
'phase' => $phase,
|
|
'request_id' => $requestId,
|
|
'consent_request_id' => $consentRequestId,
|
|
'consent_id' => $consentId,
|
|
'message' => $message,
|
|
'updated_at' => trim((string) ($row['updated_at'] ?? '')),
|
|
'operation' => strtolower(trim((string) ($row['operation'] ?? ''))),
|
|
'status' => strtolower(trim((string) ($row['status'] ?? ''))),
|
|
];
|
|
$bestGrantedOrCompletedId = $currentId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! is_array($best)) {
|
|
return $snapshot;
|
|
}
|
|
|
|
// Guardrail: if top-ranked row is transient pending/not-found, but we already
|
|
// have a more recent granted/completed record, prefer the success state.
|
|
if (
|
|
is_array($bestGrantedOrCompleted)
|
|
&& in_array((string) ($best['phase'] ?? ''), ['REQUESTED', 'PENDING'], true)
|
|
&& (string) ($best['status'] ?? '') === 'failed'
|
|
&& stripos((string) ($best['message'] ?? ''), 'processed by bridge') !== false
|
|
) {
|
|
$best = $bestGrantedOrCompleted;
|
|
}
|
|
|
|
if (($best['request_id'] ?? '') === '') {
|
|
foreach ($rows as $row) {
|
|
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
|
|
if (! is_array($decoded)) {
|
|
$decoded = [];
|
|
}
|
|
$fallbackRequestId = trim((string) (
|
|
$row['gateway_request_id']
|
|
?? $row['request_id']
|
|
?? $decoded['gateway_request_id']
|
|
?? $decoded['request_id']
|
|
?? $decoded['requestId']
|
|
?? ''
|
|
));
|
|
if ($fallbackRequestId !== '') {
|
|
$best['request_id'] = $fallbackRequestId;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$snapshot['phase'] = (string) ($best['phase'] ?? 'REQUESTED');
|
|
$snapshot['request_id'] = (string) ($best['request_id'] ?? '');
|
|
$snapshot['consent_request_id'] = (string) ($best['consent_request_id'] ?? '');
|
|
$snapshot['consent_id'] = (string) ($best['consent_id'] ?? '');
|
|
$snapshot['message'] = (string) ($best['message'] ?? 'Last status loaded from previous ABDM sync.');
|
|
$snapshot['updated_at'] = (string) ($best['updated_at'] ?? '');
|
|
$snapshot['operation'] = (string) ($best['operation'] ?? '');
|
|
$snapshot['status'] = (string) ($best['status'] ?? '');
|
|
|
|
// Check if latest consent session has expired based on erase_at/dataEraseAt
|
|
if ($latestSessionStartId > 0 && $this->db->tableExists('abdm_hiu_workflows')) {
|
|
$anchorRow = $this->db->table('abdm_hiu_workflows')
|
|
->select(['id', 'workflow_state', 'request_json', 'response_json', 'expired_at'])
|
|
->where('id', $latestSessionStartId)
|
|
->get(1)
|
|
->getRowArray();
|
|
if (is_array($anchorRow)) {
|
|
$anchorEraseAt = $this->extractDataEraseAtFromWorkflowRow($anchorRow);
|
|
$isAnchorExpired = false;
|
|
if (strtoupper(trim((string) ($anchorRow['workflow_state'] ?? ''))) === 'EXPIRED') {
|
|
$isAnchorExpired = true;
|
|
} elseif ($anchorEraseAt !== '') {
|
|
$anchorEraseTs = strtotime($anchorEraseAt);
|
|
if ($anchorEraseTs !== false && $anchorEraseTs <= time()) {
|
|
$isAnchorExpired = true;
|
|
}
|
|
}
|
|
if ($isAnchorExpired) {
|
|
$snapshot['phase'] = 'EXPIRED';
|
|
$snapshot['message'] = 'Consent expired on ' . ($anchorEraseAt ? date('d/m/Y h:i:s A', strtotime($anchorEraseAt)) : 'an earlier date') . '. Start a new request to fetch updated records.';
|
|
$snapshot['restart_required'] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// A consent request stuck in REQUESTED/PENDING for more than ABDM_PENDING_STALE_SECONDS
|
|
// (1 hour) with no GRANTED/DENIED callback from the bridge is considered abandoned by
|
|
// PHR/ABHA — the patient likely never saw/approved it, or the bridge silently dropped
|
|
// it. Flag it so the UI/auto-flow can start a brand-new consent request instead of
|
|
// resuming (and thus indefinitely polling) a request that will never resolve.
|
|
$isStalePending = false;
|
|
if (in_array($snapshot['phase'], ['REQUESTED', 'PENDING'], true) && $snapshot['updated_at'] !== '') {
|
|
try {
|
|
$updatedAt = new \DateTimeImmutable($snapshot['updated_at'], new \DateTimeZone('Asia/Kolkata'));
|
|
$nowIst = new \DateTimeImmutable('now', new \DateTimeZone('Asia/Kolkata'));
|
|
$pendingAgeSeconds = $nowIst->getTimestamp() - $updatedAt->getTimestamp();
|
|
$isStalePending = $pendingAgeSeconds > self::ABDM_PENDING_STALE_SECONDS;
|
|
} catch (\Throwable $e) {
|
|
$isStalePending = false;
|
|
}
|
|
}
|
|
|
|
if ($isStalePending) {
|
|
$snapshot['message'] = 'Previous consent request has been pending for over 1 hour with no response from ABDM/PHR. Click "Fetch ABDM Records" to start a fresh request.';
|
|
}
|
|
|
|
$snapshot['restart_required'] = (bool) (
|
|
($snapshot['phase'] === 'FAILED')
|
|
|| (($snapshot['phase'] === 'REQUESTED')
|
|
&& ($snapshot['status'] === 'failed')
|
|
&& stripos((string) ($snapshot['message'] ?? ''), 'not found on bridge') !== false)
|
|
|| $isStalePending
|
|
);
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
/**
|
|
* Builds the requested-vs-granted Health Information Type breakdown for a patient's
|
|
* current ABDM consent artifact, for the "View Consent" detail modal.
|
|
*
|
|
* Per ABDM M3 spec (confirmed with bridge team 2026-07-28): revocation is
|
|
* whole-artifact only (no partial per-HI-type revoke), so a single overall
|
|
* `status` + the granted `hi_types` array is sufficient to compute each
|
|
* requested type's effective status:
|
|
* - GRANTED : type is in the granted hi_types array and artifact status is GRANTED
|
|
* - DENIED : type was requested but is NOT in the granted hi_types array
|
|
* - REVOKED : artifact status is REVOKED (applies to the whole artifact)
|
|
* - EXPIRED : artifact status is EXPIRED
|
|
* - REQUESTED: artifact is still pending (no GRANTED/REVOKED/EXPIRED yet)
|
|
*/
|
|
private function getAbdmConsentArtifactDetail(string $abhaAddress): array
|
|
{
|
|
$abhaAddress = trim($abhaAddress);
|
|
if ($abhaAddress === '' || ! $this->db->tableExists('abdm_hiu_workflows')) {
|
|
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
|
|
}
|
|
|
|
$rows = $this->fetchAbdmWorkflowRows($abhaAddress);
|
|
if ($rows === []) {
|
|
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
|
|
}
|
|
|
|
$sessions = $this->groupWorkflowRowsIntoSessions($rows);
|
|
if ($sessions === []) {
|
|
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
|
|
}
|
|
|
|
// Sessions are chronological (oldest first). Skip synthetic "orphan"
|
|
// sessions with no owning CONSENT_REQUEST row (stray per-artifact
|
|
// re-poll rows that never correlated back to a real request, e.g. a
|
|
// one-off manual test call) so the most recent REAL request is used.
|
|
for ($i = count($sessions) - 1; $i >= 0; $i--) {
|
|
$hasAnchor = false;
|
|
foreach ($sessions[$i] as $sessionRow) {
|
|
if (strtoupper(trim((string) ($sessionRow['operation'] ?? ''))) === 'CONSENT_REQUEST') {
|
|
$hasAnchor = true;
|
|
break;
|
|
}
|
|
}
|
|
if ($hasAnchor) {
|
|
return $this->computeConsentSessionDetail($sessions[$i], $abhaAddress);
|
|
}
|
|
}
|
|
|
|
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
|
|
}
|
|
|
|
/**
|
|
* Builds one detail entry (same shape as getAbdmConsentArtifactDetail) per
|
|
* distinct consent request the patient has had over time, for the "Consent
|
|
* Request History" list — most recent first.
|
|
*/
|
|
private function getAbdmConsentRequestsList(string $abhaAddress): array
|
|
{
|
|
$abhaAddress = trim($abhaAddress);
|
|
if ($abhaAddress === '' || ! $this->db->tableExists('abdm_hiu_workflows')) {
|
|
return ['ok' => 1, 'requests' => []];
|
|
}
|
|
|
|
$rows = $this->fetchAbdmWorkflowRows($abhaAddress);
|
|
if ($rows === []) {
|
|
return ['ok' => 1, 'requests' => []];
|
|
}
|
|
|
|
$sessions = $this->groupWorkflowRowsIntoSessions($rows);
|
|
$requests = [];
|
|
foreach ($sessions as $sessionRows) {
|
|
// Skip synthetic "orphan" sessions with no owning CONSENT_REQUEST
|
|
// row (stray per-artifact re-poll rows whose internal request_id
|
|
// never correlated back to a real request -- e.g. a one-off manual
|
|
// test call). These don't represent an actual request the user
|
|
// initiated and would otherwise show up as extra incomplete rows
|
|
// in the Consent Request History table.
|
|
$hasAnchor = false;
|
|
foreach ($sessionRows as $sessionRow) {
|
|
if (strtoupper(trim((string) ($sessionRow['operation'] ?? ''))) === 'CONSENT_REQUEST') {
|
|
$hasAnchor = true;
|
|
break;
|
|
}
|
|
}
|
|
if (! $hasAnchor) {
|
|
continue;
|
|
}
|
|
|
|
$detail = $this->computeConsentSessionDetail($sessionRows, $abhaAddress);
|
|
if ((int) ($detail['ok'] ?? 0) === 1) {
|
|
$requests[] = $detail['consent'];
|
|
}
|
|
}
|
|
|
|
return ['ok' => 1, 'requests' => array_reverse($requests)];
|
|
}
|
|
|
|
/**
|
|
* Fetches the raw consent-related workflow rows for a patient's ABHA address,
|
|
* most recent first. Shared by getAbdmConsentArtifactDetail() and
|
|
* getAbdmConsentRequestsList().
|
|
*/
|
|
private function fetchAbdmWorkflowRows(string $abhaAddress): array
|
|
{
|
|
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
|
|
|
|
// `data_fetch` / `hi_data_push_callback` rows can carry the ENTIRE
|
|
// decrypted FHIR bundle in response_json -- including base64 scanned
|
|
// document attachments that are sometimes multiple MB per row. None of
|
|
// the fields this list actually reads out of response_json (consent
|
|
// status, hi_types, granted_at) ever appear in those bulk-data
|
|
// payloads, so cap it out at the SQL level for those two operations to
|
|
// avoid buffering tens/hundreds of MB into PHP memory just to build a
|
|
// consent-history list (this previously caused a fatal "Allowed memory
|
|
// size exhausted" error once enough data_fetch rows accumulated).
|
|
$responseJsonExpr = "(CASE WHEN operation IN ('data_fetch', 'hi_data_push_callback') THEN NULL ELSE response_json END) AS response_json";
|
|
$select = ['id', 'operation', 'workflow_state', 'status', 'request_id', 'consent_id', 'hfr_id', 'created_at', 'updated_at', 'completed_at', 'expired_at', 'revoked_at', 'last_error', 'http_code', 'request_json', $responseJsonExpr];
|
|
foreach (['abdm_consent_request_id', 'abdm_consent_artifact_id', 'gateway_request_id'] as $optionalField) {
|
|
if (in_array($optionalField, $fields, true)) {
|
|
$select[] = $optionalField;
|
|
}
|
|
}
|
|
$selectSql = implode(', ', $select);
|
|
|
|
// The per-artifact re-poll loop (M3HiuWorkflowService::pollNatGateway())
|
|
// can generate many `data_fetch` rows per consent per cron cycle. A flat
|
|
// "most recent 200 rows" window can therefore push an older but still-
|
|
// relevant CONSENT_REQUEST anchor row out of range, which breaks
|
|
// groupWorkflowRowsIntoSessions() (it requires every session to start
|
|
// with its own CONSENT_REQUEST row) and produces a blank/orphaned
|
|
// session in the UI. Always include every CONSENT_REQUEST row for this
|
|
// ABHA (there are only ever a handful) regardless of the recent-activity
|
|
// window, then merge with the most recent 200 rows of any operation.
|
|
$anchorRows = $this->db->table('abdm_hiu_workflows')
|
|
->select($selectSql)
|
|
->where('abha_address', $abhaAddress)
|
|
->where('operation', 'consent_request')
|
|
->orderBy('id', 'DESC')
|
|
->get(100)
|
|
->getResultArray();
|
|
|
|
$recentRows = $this->db->table('abdm_hiu_workflows')
|
|
->select($selectSql)
|
|
->where('abha_address', $abhaAddress)
|
|
->whereIn('operation', [
|
|
'consent_request',
|
|
'consent_status',
|
|
'consent_reconcile',
|
|
'data_fetch',
|
|
'consent_callback',
|
|
'hi_on_request_callback',
|
|
'hi_data_push_callback',
|
|
])
|
|
->orderBy('id', 'DESC')
|
|
->get(200)
|
|
->getResultArray();
|
|
|
|
$merged = [];
|
|
foreach (array_merge($anchorRows, $recentRows) as $row) {
|
|
$merged[(int) $row['id']] = $row;
|
|
}
|
|
krsort($merged);
|
|
|
|
return array_values($merged);
|
|
}
|
|
|
|
/**
|
|
* Splits a DESC-by-id set of workflow rows into distinct consent request
|
|
* "sessions", correlating rows by their STABLE identifiers (request_id /
|
|
* abdm_consent_request_id / consent_id) rather than simple chronological
|
|
* proximity to the most recent CONSENT_REQUEST row.
|
|
*
|
|
* IMPORTANT: a naive "everything after the newest CONSENT_REQUEST row
|
|
* belongs to it" split (the original implementation) breaks as soon as
|
|
* background polling (M3HiuWorkflowService::pollNatGateway()) reconciles
|
|
* EVERY historical consent_request in a single batch -- those older
|
|
* sessions' consent_reconcile/data_fetch rows all land chronologically
|
|
* AFTER a brand new consent_request row and would be misattributed to it,
|
|
* silently merging an unrelated older session's facility documents into
|
|
* the newest session's "Show Data" view (verified 2026-07-30: a 4-facility
|
|
* session displayed a completely different, older single-facility
|
|
* session's documents because of this).
|
|
*
|
|
* Each CONSENT_REQUEST row starts a new session keyed by its own
|
|
* `request_id`. Every other row is attributed to a session by (in order
|
|
* of preference): (1) a previously-learned abdm_consent_request_id match,
|
|
* (2) a previously-learned request_id/consent_id match, (3) falling back
|
|
* to the most recently opened session that has not yet resolved its own
|
|
* abdm_consent_request_id (covers the very first reconcile call right
|
|
* after creation, which sometimes uses the gateway's own freshly-minted
|
|
* request_id instead of preserving HMS's tracking id). Once a session's
|
|
* abdm_consent_request_id/request_id is known, that binding persists for
|
|
* every later row regardless of how much later it arrives.
|
|
*
|
|
* @param array<int, array<string, mixed>> $rows rows ordered DESC by id
|
|
* @return array<int, array<int, array<string, mixed>>>
|
|
*/
|
|
private function groupWorkflowRowsIntoSessions(array $rows): array
|
|
{
|
|
$chronological = array_reverse($rows);
|
|
|
|
$sessions = [];
|
|
$order = [];
|
|
$abdmIdToAnchor = [];
|
|
$requestIdToAnchor = [];
|
|
$anchorHasAbdmId = [];
|
|
$openAnchor = null;
|
|
|
|
foreach ($chronological as $row) {
|
|
$operation = strtoupper(trim((string) ($row['operation'] ?? '')));
|
|
$rowRequestId = trim((string) ($row['request_id'] ?? ''));
|
|
$rowAbdmConsentRequestId = trim((string) ($row['abdm_consent_request_id'] ?? ''));
|
|
$rowConsentId = trim((string) ($row['consent_id'] ?? ''));
|
|
$rowGatewayRequestId = trim((string) ($row['gateway_request_id'] ?? ''));
|
|
|
|
if ($operation === 'CONSENT_REQUEST') {
|
|
$anchorKey = 'anchor_' . count($order);
|
|
$order[] = $anchorKey;
|
|
$sessions[$anchorKey] = [$row];
|
|
if ($rowRequestId !== '') {
|
|
$requestIdToAnchor[$rowRequestId] = $anchorKey;
|
|
}
|
|
if ($rowGatewayRequestId !== '') {
|
|
$requestIdToAnchor[$rowGatewayRequestId] = $anchorKey;
|
|
}
|
|
// Reconcile/status rows usually carry only the ABDM ids, so the
|
|
// anchor must publish its own ids too or those rows cannot match.
|
|
if ($rowAbdmConsentRequestId !== '') {
|
|
$abdmIdToAnchor[$rowAbdmConsentRequestId] = $anchorKey;
|
|
$requestIdToAnchor[$rowAbdmConsentRequestId] = $anchorKey;
|
|
$anchorHasAbdmId[$anchorKey] = true;
|
|
}
|
|
if ($rowConsentId !== '') {
|
|
$abdmIdToAnchor[$rowConsentId] = $anchorKey;
|
|
$requestIdToAnchor[$rowConsentId] = $anchorKey;
|
|
$cleanConsentId = trim((string) explode(':', $rowConsentId)[0]);
|
|
$abdmIdToAnchor[$cleanConsentId] = $anchorKey;
|
|
$requestIdToAnchor[$cleanConsentId] = $anchorKey;
|
|
}
|
|
$openAnchor = $anchorKey;
|
|
continue;
|
|
}
|
|
|
|
$anchorKey = null;
|
|
if ($rowAbdmConsentRequestId !== '' && isset($abdmIdToAnchor[$rowAbdmConsentRequestId])) {
|
|
$anchorKey = $abdmIdToAnchor[$rowAbdmConsentRequestId];
|
|
} elseif ($rowRequestId !== '' && isset($requestIdToAnchor[$rowRequestId])) {
|
|
$anchorKey = $requestIdToAnchor[$rowRequestId];
|
|
} elseif ($rowGatewayRequestId !== '' && isset($requestIdToAnchor[$rowGatewayRequestId])) {
|
|
$anchorKey = $requestIdToAnchor[$rowGatewayRequestId];
|
|
} elseif ($rowConsentId !== '' && isset($requestIdToAnchor[$rowConsentId])) {
|
|
$anchorKey = $requestIdToAnchor[$rowConsentId];
|
|
} elseif ($rowConsentId !== '' && isset($requestIdToAnchor[trim((string) explode(':', $rowConsentId)[0])])) {
|
|
$anchorKey = $requestIdToAnchor[trim((string) explode(':', $rowConsentId)[0])];
|
|
} elseif ($openAnchor !== null && ($rowAbdmConsentRequestId === '' || empty($anchorHasAbdmId[$openAnchor]))) {
|
|
// Covers the first reconcile that discovers the ABDM id. A row
|
|
// naming a consent request that belongs elsewhere must not be
|
|
// guessed onto this session — that hid DENIED/EXPIRED results.
|
|
$anchorKey = $openAnchor;
|
|
}
|
|
|
|
if ($anchorKey === null) {
|
|
// No matching anchor at all (should not normally happen) --
|
|
// start a synthetic session so the row is not silently dropped.
|
|
$anchorKey = 'orphan_' . count($order);
|
|
$order[] = $anchorKey;
|
|
$sessions[$anchorKey] = [];
|
|
}
|
|
|
|
$sessions[$anchorKey][] = $row;
|
|
|
|
if ($anchorKey === $openAnchor) {
|
|
$openAnchor = null;
|
|
}
|
|
if ($rowAbdmConsentRequestId !== '') {
|
|
$abdmIdToAnchor[$rowAbdmConsentRequestId] = $anchorKey;
|
|
$anchorHasAbdmId[$anchorKey] = true;
|
|
}
|
|
if ($rowRequestId !== '') {
|
|
$requestIdToAnchor[$rowRequestId] = $anchorKey;
|
|
}
|
|
if ($rowGatewayRequestId !== '') {
|
|
$requestIdToAnchor[$rowGatewayRequestId] = $anchorKey;
|
|
}
|
|
if ($rowConsentId !== '') {
|
|
$requestIdToAnchor[$rowConsentId] = $anchorKey;
|
|
$cleanConsentId = trim((string) explode(':', $rowConsentId)[0]);
|
|
$abdmIdToAnchor[$cleanConsentId] = $anchorKey;
|
|
$requestIdToAnchor[$cleanConsentId] = $anchorKey;
|
|
}
|
|
}
|
|
|
|
$result = [];
|
|
foreach ($order as $anchorKey) {
|
|
if (! empty($sessions[$anchorKey])) {
|
|
$result[] = $sessions[$anchorKey];
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Computes the requested-vs-granted Health Information Type breakdown for a
|
|
* single consent request "session" (one CONSENT_REQUEST row plus its
|
|
* subsequent status/reconcile/data_fetch/callback rows).
|
|
*
|
|
* @param array<int, array<string, mixed>> $rows the session's rows (any order)
|
|
*/
|
|
private function computeConsentSessionDetail(array $rows, string $abhaAddress): array
|
|
{
|
|
if ($rows === []) {
|
|
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
|
|
}
|
|
|
|
$best = null;
|
|
$bestPriority = -1;
|
|
$bestDecoded = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
|
|
if (! is_array($decoded)) {
|
|
$decoded = [];
|
|
}
|
|
|
|
$rawConsentStatus = '';
|
|
if (isset($decoded['consent']) && is_array($decoded['consent'])) {
|
|
$rawConsentStatus = trim((string) (
|
|
$decoded['consent']['status']
|
|
?? $decoded['consent']['consent_status']
|
|
?? $decoded['consent']['consentStatus']
|
|
?? ''
|
|
));
|
|
}
|
|
if ($rawConsentStatus === '' && isset($decoded['consentDetail']) && is_array($decoded['consentDetail'])) {
|
|
$rawConsentStatus = trim((string) ($decoded['consentDetail']['status'] ?? ''));
|
|
}
|
|
if ($rawConsentStatus === '' && isset($decoded['data']['consent']) && is_array($decoded['data']['consent'])) {
|
|
$rawConsentStatus = trim((string) ($decoded['data']['consent']['status'] ?? ''));
|
|
}
|
|
if ($rawConsentStatus === '') {
|
|
$rawConsentStatus = trim((string) ($decoded['consent_status'] ?? $decoded['consentStatus'] ?? ''));
|
|
}
|
|
if ($rawConsentStatus === '' || in_array(strtolower($rawConsentStatus), ['success', 'ok', 'failed', 'error', 'status_checked'], true)) {
|
|
$topStatus = trim((string) ($decoded['status'] ?? ''));
|
|
if (! in_array(strtolower($topStatus), ['success', 'ok', 'failed', 'error', 'status_checked', '1', '0'], true)) {
|
|
$rawConsentStatus = $topStatus;
|
|
}
|
|
}
|
|
$rawConsentStatus = strtoupper($rawConsentStatus);
|
|
$operation = strtoupper(trim((string) ($row['operation'] ?? '')));
|
|
$status = strtoupper(trim((string) ($row['status'] ?? '')));
|
|
$state = strtoupper(trim((string) ($row['workflow_state'] ?? '')));
|
|
|
|
$phase = 'REQUESTED';
|
|
$priority = 120;
|
|
|
|
// NOTE: the `status` column only reflects whether the underlying API
|
|
// call/poll succeeded (no HTTP/transport error) — it does NOT mean
|
|
// data was actually received (see M3HiuWorkflowService::runOperation()
|
|
// / ingestHealthInformationCallback(), which always set status=success
|
|
// for any error-free response, even one reporting decrypted_data=[]
|
|
// and consent/session status "requested"). Whether data truly arrived
|
|
// is tracked separately in workflow_state (DATA_PENDING vs
|
|
// DATA_RECEIVED), so require that here instead of the generic status.
|
|
if (($operation === 'DATA_FETCH' || $operation === 'HI_DATA_PUSH_CALLBACK') && $status === 'SUCCESS' && $state === 'DATA_RECEIVED') {
|
|
$phase = 'COMPLETED';
|
|
$priority = 500;
|
|
} elseif (in_array($rawConsentStatus, ['GRANTED', 'APPROVED', 'ACTIVE'], true)) {
|
|
$phase = 'GRANTED';
|
|
$priority = 430;
|
|
} elseif ($rawConsentStatus === 'REVOKED') {
|
|
$phase = 'REVOKED';
|
|
$priority = 320;
|
|
} elseif ($rawConsentStatus === 'EXPIRED') {
|
|
$phase = 'EXPIRED';
|
|
$priority = 310;
|
|
} elseif ($rawConsentStatus === 'DENIED') {
|
|
$phase = 'DENIED';
|
|
$priority = 300;
|
|
} elseif (in_array($state, ['DATA_RECEIVED', 'COMPLETED'], true)) {
|
|
$phase = 'GRANTED';
|
|
$priority = 480;
|
|
} elseif (in_array($state, ['GRANTED', 'APPROVED', 'ACTIVE'], true)) {
|
|
$phase = 'GRANTED';
|
|
$priority = 420;
|
|
} elseif ($state === 'REVOKED') {
|
|
$phase = 'REVOKED';
|
|
$priority = 300;
|
|
} elseif ($state === 'EXPIRED') {
|
|
$phase = 'EXPIRED';
|
|
$priority = 290;
|
|
} elseif ($status === 'FAILED' && $operation === 'CONSENT_REQUEST') {
|
|
$phase = 'FAILED';
|
|
$priority = 260;
|
|
} elseif ($status === 'FAILED') {
|
|
$phase = 'REQUESTED';
|
|
$priority = 190;
|
|
} elseif (in_array($state, ['REQUESTED', 'PENDING', 'STATUS_CHECKED'], true)) {
|
|
$phase = 'REQUESTED';
|
|
$priority = 180;
|
|
}
|
|
|
|
if ($best === null || $priority > $bestPriority) {
|
|
$best = $row;
|
|
$best['_phase'] = $phase;
|
|
$bestPriority = $priority;
|
|
$bestDecoded = $decoded;
|
|
}
|
|
}
|
|
|
|
if ($best === null) {
|
|
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
|
|
}
|
|
|
|
$phase = (string) $best['_phase'];
|
|
|
|
// Phase selection above is priority-based, so a GRANTED/COMPLETED row
|
|
// would otherwise outrank a LATER revocation/expiry/denial reported by
|
|
// the PHR app. A terminal outcome recorded in this session wins.
|
|
$terminalPhase = '';
|
|
$terminalRowId = 0;
|
|
foreach ($rows as $row) {
|
|
$rowDecoded = json_decode((string) ($row['response_json'] ?? ''), true);
|
|
if (! is_array($rowDecoded)) {
|
|
$rowDecoded = [];
|
|
}
|
|
$rowStatus = '';
|
|
if (isset($rowDecoded['consent']) && is_array($rowDecoded['consent'])) {
|
|
$rowStatus = trim((string) (
|
|
$rowDecoded['consent']['status']
|
|
?? $rowDecoded['consent']['consent_status']
|
|
?? ''
|
|
));
|
|
}
|
|
if ($rowStatus === '' && isset($rowDecoded['consentDetail']) && is_array($rowDecoded['consentDetail'])) {
|
|
$rowStatus = trim((string) ($rowDecoded['consentDetail']['status'] ?? ''));
|
|
}
|
|
if ($rowStatus === '' && isset($rowDecoded['data']['consent']) && is_array($rowDecoded['data']['consent'])) {
|
|
$rowStatus = trim((string) ($rowDecoded['data']['consent']['status'] ?? ''));
|
|
}
|
|
if ($rowStatus === '') {
|
|
$rowStatus = trim((string) ($rowDecoded['consent_status'] ?? $rowDecoded['consentStatus'] ?? ''));
|
|
}
|
|
if ($rowStatus === '' || in_array(strtolower($rowStatus), ['success', 'ok', 'failed', 'error', 'status_checked'], true)) {
|
|
$rowState = strtoupper(trim((string) ($row['workflow_state'] ?? '')));
|
|
if (in_array($rowState, ['REVOKED', 'EXPIRED', 'DENIED'], true)) {
|
|
$rowStatus = $rowState;
|
|
}
|
|
}
|
|
$rowStatus = strtoupper($rowStatus);
|
|
if (! in_array($rowStatus, ['REVOKED', 'EXPIRED', 'DENIED'], true)) {
|
|
continue;
|
|
}
|
|
$rowId = (int) ($row['id'] ?? 0);
|
|
if ($rowId >= $terminalRowId) {
|
|
$terminalPhase = $rowStatus;
|
|
$terminalRowId = $rowId;
|
|
}
|
|
}
|
|
if ($terminalPhase !== '') {
|
|
$phase = $terminalPhase;
|
|
}
|
|
|
|
// IMPORTANT: do NOT read consent_id/consent_request_id only from the
|
|
// "best" (highest-priority phase) row. The initial consent_request row
|
|
// and its immediate consent_reconcile/consent_status follow-up often
|
|
// tie on priority (both still "REQUESTED" phase) while only the LATER
|
|
// row actually carries the real consent_id assigned by the bridge —
|
|
// and the priority tie-break above keeps whichever row was seen FIRST
|
|
// (chronologically oldest), so the blank consent_id from the original
|
|
// consent_request row was winning and hiding the real id (confirmed
|
|
// via abdm_hiu_workflows: consent_request row had consent_id='' while
|
|
// its own consent_reconcile row moments later had a real UUID). Scan
|
|
// every row in the session (already chronological, oldest first) and
|
|
// keep the LAST non-empty value found so the most recent known id wins.
|
|
$consentId = '';
|
|
$consentRequestId = '';
|
|
foreach ($rows as $row) {
|
|
$rowDecoded = json_decode((string) ($row['response_json'] ?? ''), true);
|
|
if (! is_array($rowDecoded)) {
|
|
$rowDecoded = [];
|
|
}
|
|
|
|
$rowConsentId = trim((string) (
|
|
$row['abdm_consent_artifact_id']
|
|
?? $row['consent_id']
|
|
?? $rowDecoded['consent_id']
|
|
?? $rowDecoded['consentId']
|
|
?? $rowDecoded['consent']['id']
|
|
?? $rowDecoded['consent']['consent_id']
|
|
?? $rowDecoded['consent']['consentId']
|
|
?? $rowDecoded['consentDetail']['id']
|
|
?? $rowDecoded['consentDetail']['consentId']
|
|
?? $rowDecoded['consent_artifact_id']
|
|
?? $rowDecoded['consent_artefact_id']
|
|
?? $rowDecoded['consentArtifactId']
|
|
?? $rowDecoded['consentArtefactId']
|
|
?? $rowDecoded['data']['consent']['id']
|
|
?? $rowDecoded['data']['consent_id']
|
|
?? ''
|
|
));
|
|
if ($rowConsentId !== '' && ! preg_match('/^REQ-/i', $rowConsentId)) {
|
|
$consentId = $rowConsentId;
|
|
}
|
|
|
|
$rowConsentRequestId = trim((string) (
|
|
$row['abdm_consent_request_id']
|
|
?? $rowDecoded['abdm_consent_request_id']
|
|
?? $rowDecoded['consent_request_id']
|
|
?? $rowDecoded['consentRequestId']
|
|
?? $rowDecoded['consent']['consent_request_id']
|
|
?? $rowDecoded['consent']['consentRequestId']
|
|
?? $rowDecoded['consentDetail']['consent_request_id']
|
|
?? $rowDecoded['consentDetail']['consentRequestId']
|
|
?? $rowDecoded['data']['consent_request_id']
|
|
?? ''
|
|
));
|
|
if ($rowConsentRequestId !== '' && ! preg_match('/^REQ-/i', $rowConsentRequestId)) {
|
|
$consentRequestId = $rowConsentRequestId;
|
|
}
|
|
}
|
|
if ($consentId === '' && $consentRequestId !== '') {
|
|
$consentId = $consentRequestId;
|
|
}
|
|
|
|
// The session should start with its own CONSENT_REQUEST row.
|
|
$consentRequestRow = null;
|
|
foreach ($rows as $row) {
|
|
if (strtoupper((string) ($row['operation'] ?? '')) === 'CONSENT_REQUEST') {
|
|
$consentRequestRow = $row;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Prefer the umbrella consent_request_id directly from the CONSENT_REQUEST row if available
|
|
if (is_array($consentRequestRow)) {
|
|
$anchorReqId = trim((string) ($consentRequestRow['abdm_consent_request_id'] ?? ''));
|
|
if ($anchorReqId === '') {
|
|
$anchorDecoded = json_decode((string) ($consentRequestRow['response_json'] ?? ''), true);
|
|
if (is_array($anchorDecoded)) {
|
|
$anchorReqId = trim((string) (
|
|
$anchorDecoded['consentRequestId']
|
|
?? $anchorDecoded['consentRequest']['id']
|
|
?? $anchorDecoded['abdm_consent_request_id']
|
|
?? $anchorDecoded['consent_request_id']
|
|
?? ''
|
|
));
|
|
}
|
|
}
|
|
if ($anchorReqId !== '' && ! preg_match('/^REQ-/i', $anchorReqId)) {
|
|
$consentRequestId = $anchorReqId;
|
|
}
|
|
}
|
|
|
|
// If $consentRequestId was set to an artifact_id, check abdm_hiu_consent_artifacts
|
|
// to resolve its true parent umbrella consent_request_id.
|
|
if ($this->db->tableExists('abdm_hiu_consent_artifacts') && $consentRequestId !== '') {
|
|
$parentArtifact = $this->db->table('abdm_hiu_consent_artifacts')
|
|
->select('consent_request_id')
|
|
->where('artifact_id', $consentRequestId)
|
|
->get(1)
|
|
->getRowArray();
|
|
if (! empty($parentArtifact['consent_request_id'])) {
|
|
if ($consentId === '') {
|
|
$consentId = $consentRequestId;
|
|
}
|
|
$consentRequestId = trim((string) $parentArtifact['consent_request_id']);
|
|
}
|
|
}
|
|
|
|
// Collect all linked facilities / sibling artifacts for this session
|
|
$sessionArtifacts = [];
|
|
$lookupReqId = $consentRequestId !== '' ? $consentRequestId : $consentId;
|
|
if ($lookupReqId !== '' && $this->db->tableExists('abdm_hiu_consent_artifacts')) {
|
|
$artFields = $this->db->getFieldNames('abdm_hiu_consent_artifacts') ?? [];
|
|
$selectCols = ['artifact_id'];
|
|
if (in_array('hfr_id', $artFields, true)) {
|
|
$selectCols[] = 'hfr_id';
|
|
}
|
|
if (in_array('hip_id', $artFields, true)) {
|
|
$selectCols[] = 'hip_id';
|
|
}
|
|
if (in_array('hip_name', $artFields, true)) {
|
|
$selectCols[] = 'hip_name';
|
|
}
|
|
if (in_array('last_status', $artFields, true)) {
|
|
$selectCols[] = 'last_status';
|
|
}
|
|
$artRows = $this->db->table('abdm_hiu_consent_artifacts')
|
|
->select(implode(', ', $selectCols))
|
|
->where('consent_request_id', $lookupReqId)
|
|
->get()
|
|
->getResultArray();
|
|
foreach ($artRows as $ar) {
|
|
$hipIdentifier = trim((string) ($ar['hip_id'] ?? $ar['hfr_id'] ?? ''));
|
|
$sessionArtifacts[] = [
|
|
'artifact_id' => trim((string) ($ar['artifact_id'] ?? '')),
|
|
'hip_id' => $hipIdentifier,
|
|
'hip_name' => trim((string) ($ar['hip_name'] ?? $hipIdentifier)),
|
|
'status' => trim((string) ($ar['last_status'] ?? '')),
|
|
];
|
|
}
|
|
}
|
|
|
|
// A status stamped directly onto this request row by a live status check or workflow completion
|
|
// is authoritative: it names this exact consent request.
|
|
$anchorState = strtoupper(trim((string) (is_array($consentRequestRow) ? ($consentRequestRow['workflow_state'] ?? '') : '')));
|
|
if (in_array($anchorState, ['GRANTED', 'APPROVED', 'ACTIVE', 'COMPLETED'], true)) {
|
|
$phase = 'GRANTED';
|
|
} elseif (in_array($anchorState, ['EXPIRED', 'DENIED', 'REVOKED'], true)) {
|
|
$phase = $anchorState;
|
|
}
|
|
|
|
// If documents have already been fetched and saved in abdm_hiu_documents for this session,
|
|
// the consent is definitively granted and active.
|
|
if (! in_array($phase, ['GRANTED', 'COMPLETED', 'EXPIRED', 'DENIED', 'REVOKED'], true)) {
|
|
$lookupRef = $consentRequestId !== '' ? $consentRequestId : $consentId;
|
|
if ($lookupRef !== '' && $this->countAbdmDocumentsForPatient((int) ($patientRow['id'] ?? 0), $lookupRef) > 0) {
|
|
$phase = 'GRANTED';
|
|
}
|
|
}
|
|
|
|
// A short, human-friendly identifier for this session (the "Request ID"
|
|
// guid is too long to display in a table column) -- use the underlying
|
|
// abdm_hiu_workflows row id of this session's own CONSENT_REQUEST row
|
|
// (falling back to whichever row scored best) since it's already a
|
|
// small, unique, DB-backed integer.
|
|
$displayId = (int) (is_array($consentRequestRow) ? ($consentRequestRow['id'] ?? 0) : 0);
|
|
if ($displayId <= 0) {
|
|
$displayId = (int) ($best['id'] ?? 0);
|
|
}
|
|
|
|
$requestedHiTypes = [];
|
|
$requestedOn = '';
|
|
$purpose = '';
|
|
$requestedFrom = '';
|
|
$requestedTo = '';
|
|
$requestedEraseAt = '';
|
|
$requestedBy = '';
|
|
$hfrId = trim((string) ($best['hfr_id'] ?? ''));
|
|
|
|
if (is_array($consentRequestRow)) {
|
|
$reqPayload = json_decode((string) ($consentRequestRow['request_json'] ?? ''), true);
|
|
if (! is_array($reqPayload)) {
|
|
$reqPayload = [];
|
|
}
|
|
$consentBlock = (array) ($reqPayload['consent'] ?? []);
|
|
$requestedHiTypes = $this->normalizeHiTypesList($consentBlock['hiTypes'] ?? $consentBlock['hi_types'] ?? []);
|
|
$requestedOn = trim((string) ($consentRequestRow['created_at'] ?? ''));
|
|
$purpose = trim((string) ($consentBlock['purpose']['text'] ?? $consentBlock['purpose']['code'] ?? ''));
|
|
$requestedFrom = trim((string) ($consentBlock['permission']['dateRange']['from'] ?? $consentBlock['permission']['date_range']['from'] ?? ''));
|
|
$requestedTo = trim((string) ($consentBlock['permission']['dateRange']['to'] ?? $consentBlock['permission']['date_range']['to'] ?? ''));
|
|
$requestedEraseAt = trim((string) ($consentBlock['permission']['dataEraseAt'] ?? $consentBlock['permission']['data_erase_at'] ?? ''));
|
|
$requestedBy = trim((string) ($consentBlock['requester']['name'] ?? ''));
|
|
if ($hfrId === '') {
|
|
$hfrId = trim((string) ($consentRequestRow['hfr_id'] ?? ''));
|
|
}
|
|
}
|
|
|
|
// Fallback scanning across session rows for missing requested metadata
|
|
foreach ($rows as $row) {
|
|
$rowReq = json_decode((string) ($row['request_json'] ?? ''), true);
|
|
$containers = [
|
|
is_array($rowReq) ? ($rowReq['consent'] ?? null) : null,
|
|
is_array($rowReq) ? ($rowReq['consentDetail'] ?? null) : null,
|
|
is_array($rowReq) ? ($rowReq['data']['consent'] ?? null) : null,
|
|
is_array($rowReq) ? $rowReq : null,
|
|
];
|
|
|
|
foreach ($containers as $c) {
|
|
if (! is_array($c)) {
|
|
continue;
|
|
}
|
|
if ($requestedFrom === '') {
|
|
$requestedFrom = trim((string) (
|
|
$c['permission']['dateRange']['from']
|
|
?? $c['permission']['date_range']['from']
|
|
?? $c['date_range']['from']
|
|
?? $c['dateRange']['from']
|
|
?? $c['valid_from']
|
|
?? $c['validFrom']
|
|
?? $c['date_from']
|
|
?? $c['dateFrom']
|
|
?? ''
|
|
));
|
|
}
|
|
if ($requestedTo === '') {
|
|
$requestedTo = trim((string) (
|
|
$c['permission']['dateRange']['to']
|
|
?? $c['permission']['date_range']['to']
|
|
?? $c['date_range']['to']
|
|
?? $c['dateRange']['to']
|
|
?? $c['valid_to']
|
|
?? $c['validTo']
|
|
?? $c['date_to']
|
|
?? $c['dateTo']
|
|
?? ''
|
|
));
|
|
}
|
|
if ($requestedEraseAt === '') {
|
|
$requestedEraseAt = trim((string) (
|
|
$c['permission']['dataEraseAt']
|
|
?? $c['permission']['data_erase_at']
|
|
?? $c['expiry']
|
|
?? $c['erase_at']
|
|
?? $c['eraseAt']
|
|
?? $c['dataEraseAt']
|
|
?? $c['data_erase_at']
|
|
?? ''
|
|
));
|
|
}
|
|
if ($purpose === '') {
|
|
$purpose = trim((string) (
|
|
$c['purpose']['text']
|
|
?? $c['purpose']['code']
|
|
?? ''
|
|
));
|
|
}
|
|
if ($requestedBy === '') {
|
|
$requestedBy = trim((string) (
|
|
$c['requester']['name']
|
|
?? $c['requester']
|
|
?? ''
|
|
));
|
|
}
|
|
}
|
|
if ($requestedOn === '') {
|
|
$requestedOn = trim((string) ($row['created_at'] ?? ''));
|
|
}
|
|
}
|
|
|
|
// Scan for GRANTED / UPDATED metadata from ABDM callback & response payloads
|
|
$grantedHiTypes = [];
|
|
$grantedOn = '';
|
|
$grantedFrom = '';
|
|
$grantedTo = '';
|
|
$grantedEraseAt = '';
|
|
|
|
foreach ($rows as $row) {
|
|
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
|
|
if (! is_array($decoded)) {
|
|
continue;
|
|
}
|
|
|
|
$containers = [
|
|
$decoded['consent'] ?? null,
|
|
$decoded['consentDetail'] ?? null,
|
|
$decoded['data']['consent'] ?? null,
|
|
$decoded['data']['consentDetail'] ?? null,
|
|
$decoded['data'] ?? null,
|
|
$decoded,
|
|
];
|
|
|
|
foreach ($containers as $c) {
|
|
if (! is_array($c)) {
|
|
continue;
|
|
}
|
|
|
|
$rowHiTypes = $this->normalizeHiTypesList($c['hi_types'] ?? $c['hiTypes'] ?? []);
|
|
if ($rowHiTypes !== []) {
|
|
$grantedHiTypes = $rowHiTypes;
|
|
}
|
|
|
|
$candGrantedOn = trim((string) (
|
|
$c['granted_at']
|
|
?? $c['grantedAt']
|
|
?? $c['permission']['granted_at']
|
|
?? $c['permission']['grantedAt']
|
|
?? ''
|
|
));
|
|
if ($candGrantedOn !== '') {
|
|
$grantedOn = $candGrantedOn;
|
|
}
|
|
|
|
$candFrom = trim((string) (
|
|
$c['date_range']['from']
|
|
?? $c['dateRange']['from']
|
|
?? $c['permission']['dateRange']['from']
|
|
?? $c['permission']['date_range']['from']
|
|
?? $c['valid_from']
|
|
?? $c['validFrom']
|
|
?? ''
|
|
));
|
|
if ($candFrom !== '') {
|
|
$grantedFrom = $candFrom;
|
|
}
|
|
|
|
$candTo = trim((string) (
|
|
$c['date_range']['to']
|
|
?? $c['dateRange']['to']
|
|
?? $c['permission']['dateRange']['to']
|
|
?? $c['permission']['date_range']['to']
|
|
?? $c['valid_to']
|
|
?? $c['validTo']
|
|
?? ''
|
|
));
|
|
if ($candTo !== '') {
|
|
$grantedTo = $candTo;
|
|
}
|
|
|
|
$candErase = trim((string) (
|
|
(is_array($c['expiry'] ?? null) ? ($c['expiry']['date'] ?? null) : ($c['expiry'] ?? null))
|
|
?? $c['permission']['dataEraseAt']
|
|
?? $c['permission']['data_erase_at']
|
|
?? $c['dataEraseAt']
|
|
?? $c['data_erase_at']
|
|
?? $c['erase_at']
|
|
?? $c['eraseAt']
|
|
?? ''
|
|
));
|
|
if ($candErase !== '') {
|
|
$grantedEraseAt = $candErase;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If this session's overall phase is GRANTED/COMPLETED but no row ever
|
|
// recorded WHICH hi_types were granted, fall back to requested types.
|
|
if ($grantedHiTypes === [] && $requestedHiTypes !== [] && in_array($phase, ['GRANTED', 'COMPLETED'], true)) {
|
|
$grantedHiTypes = $requestedHiTypes;
|
|
}
|
|
|
|
// Effective values: if granted values exist from ABDM, they represent the official patient-approved values
|
|
$validFrom = $grantedFrom !== '' ? $grantedFrom : $requestedFrom;
|
|
$validTo = $grantedTo !== '' ? $grantedTo : $requestedTo;
|
|
$eraseAt = $grantedEraseAt !== '' ? $grantedEraseAt : $requestedEraseAt;
|
|
|
|
// Detect if patient modified the date range or expiry in the PHR app
|
|
$isDateFromModified = false;
|
|
if ($requestedFrom !== '' && $grantedFrom !== '') {
|
|
$tsReqFrom = strtotime($requestedFrom);
|
|
$tsGntFrom = strtotime($grantedFrom);
|
|
if ($tsReqFrom !== false && $tsGntFrom !== false && date('Y-m-d', $tsReqFrom) !== date('Y-m-d', $tsGntFrom)) {
|
|
$isDateFromModified = true;
|
|
}
|
|
}
|
|
|
|
$isDateToModified = false;
|
|
if ($requestedTo !== '' && $grantedTo !== '') {
|
|
$tsReqTo = strtotime($requestedTo);
|
|
$tsGntTo = strtotime($grantedTo);
|
|
if ($tsReqTo !== false && $tsGntTo !== false && date('Y-m-d', $tsReqTo) !== date('Y-m-d', $tsGntTo)) {
|
|
$isDateToModified = true;
|
|
}
|
|
}
|
|
|
|
$isEraseAtModified = false;
|
|
if ($requestedEraseAt !== '' && $grantedEraseAt !== '') {
|
|
$tsReqErase = strtotime($requestedEraseAt);
|
|
$tsGntErase = strtotime($grantedEraseAt);
|
|
if ($tsReqErase !== false && $tsGntErase !== false && abs($tsReqErase - $tsGntErase) > 60) {
|
|
$isEraseAtModified = true;
|
|
}
|
|
}
|
|
|
|
$revokedOn = trim((string) ($best['revoked_at'] ?? ''));
|
|
$expiredOn = trim((string) ($best['expired_at'] ?? ''));
|
|
if (in_array($phase, ['GRANTED', 'COMPLETED'], true) && $grantedOn === '') {
|
|
$grantedOn = trim((string) ($bestDecoded['granted_at'] ?? $best['updated_at'] ?? ''));
|
|
}
|
|
|
|
// ABDM M3 HIU compliance: evaluate consent expiration against effective erase_at (dataEraseAt)
|
|
$isConsentExpired = false;
|
|
$effectiveEraseTimestamp = ($eraseAt !== '') ? strtotime($eraseAt) : false;
|
|
if ($effectiveEraseTimestamp !== false) {
|
|
if ($effectiveEraseTimestamp <= time()) {
|
|
$isConsentExpired = true;
|
|
}
|
|
} elseif ($expiredOn !== '') {
|
|
$expTime = strtotime($expiredOn);
|
|
if ($expTime !== false && $expTime <= time()) {
|
|
$isConsentExpired = true;
|
|
}
|
|
} elseif ($phase === 'EXPIRED') {
|
|
$isConsentExpired = true;
|
|
}
|
|
|
|
// If effective eraseAt is in the future, the consent is NOT expired!
|
|
// (Revive from any premature EXPIRED state stamped by legacy code)
|
|
if (! $isConsentExpired && $phase === 'EXPIRED') {
|
|
$expiredOn = '';
|
|
if ($status === 'SUCCESS' && ($state === 'DATA_RECEIVED' || in_array($bestPriority, [480, 490, 500], true))) {
|
|
$phase = 'COMPLETED';
|
|
} elseif ($grantedOn !== '' || in_array($rawConsentStatus, ['GRANTED', 'APPROVED', 'ACTIVE'], true) || in_array($state, ['GRANTED', 'APPROVED', 'ACTIVE'], true)) {
|
|
$phase = 'GRANTED';
|
|
}
|
|
}
|
|
|
|
if ($isConsentExpired) {
|
|
$phase = 'EXPIRED';
|
|
if ($expiredOn === '') {
|
|
$expiredOn = $eraseAt !== '' ? $eraseAt : (string) ($best['expired_at'] ?? date('Y-m-d H:i:s'));
|
|
}
|
|
|
|
// Persist terminal EXPIRED state to abdm_hiu_workflows for this session
|
|
if ($this->db->tableExists('abdm_hiu_workflows')) {
|
|
$sessionIdsToUpdate = [];
|
|
foreach ($rows as $r) {
|
|
$rState = strtoupper(trim((string) ($r['workflow_state'] ?? '')));
|
|
if ($rState !== 'EXPIRED' && ! empty($r['id'])) {
|
|
$sessionIdsToUpdate[] = (int) $r['id'];
|
|
}
|
|
}
|
|
if ($sessionIdsToUpdate !== []) {
|
|
$updateData = ['workflow_state' => 'EXPIRED'];
|
|
$wfFields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
|
|
if (in_array('expired_at', $wfFields, true)) {
|
|
$updateData['expired_at'] = date('Y-m-d H:i:s', (isset($eraseTimestamp) && $eraseTimestamp) ? $eraseTimestamp : time());
|
|
}
|
|
$this->db->table('abdm_hiu_workflows')->whereIn('id', $sessionIdsToUpdate)->update($updateData);
|
|
}
|
|
}
|
|
|
|
// ABDM M3 HIU compliance: erase stored health records once dataEraseAt is reached
|
|
$purgeRefs = array_filter([$consentRequestId, $consentId]);
|
|
if ($purgeRefs !== []) {
|
|
$sessionDocIds = $this->resolveAllRelatedConsentIds($consentRequestId !== '' ? $consentRequestId : $consentId);
|
|
if ($sessionDocIds !== []) {
|
|
$purgeRefs = array_merge($purgeRefs, $sessionDocIds);
|
|
}
|
|
$this->purgeAbdmDocumentsByRefs($purgeRefs);
|
|
}
|
|
}
|
|
|
|
$items = [];
|
|
$typesForItems = $requestedHiTypes !== [] ? $requestedHiTypes : $grantedHiTypes;
|
|
foreach ($typesForItems as $hiType) {
|
|
$itemStatus = 'REQUESTED';
|
|
$itemTimestamp = $requestedOn;
|
|
|
|
if ($phase === 'REVOKED') {
|
|
$itemStatus = 'REVOKED';
|
|
$itemTimestamp = $revokedOn;
|
|
} elseif ($phase === 'EXPIRED') {
|
|
$itemStatus = 'EXPIRED';
|
|
$itemTimestamp = $expiredOn;
|
|
} elseif (in_array($phase, ['GRANTED', 'COMPLETED'], true)) {
|
|
if (in_array($hiType, $grantedHiTypes, true)) {
|
|
$itemStatus = 'GRANTED';
|
|
$itemTimestamp = $grantedOn;
|
|
} else {
|
|
$itemStatus = 'DENIED';
|
|
$itemTimestamp = $grantedOn;
|
|
}
|
|
} elseif ($phase === 'FAILED') {
|
|
$itemStatus = 'FAILED';
|
|
$itemTimestamp = trim((string) ($best['updated_at'] ?? ''));
|
|
}
|
|
|
|
$items[] = [
|
|
'document_name' => $hiType,
|
|
'permission' => 'VIEW',
|
|
'status' => $itemStatus,
|
|
'timestamp' => $itemTimestamp,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => 1,
|
|
'consent' => [
|
|
'id' => $displayId,
|
|
'request_id' => trim((string) (is_array($consentRequestRow) ? ($consentRequestRow['request_id'] ?? '') : ($best['request_id'] ?? ''))),
|
|
'gateway_request_id' => trim((string) (is_array($consentRequestRow) ? ($consentRequestRow['gateway_request_id'] ?? '') : ($best['gateway_request_id'] ?? ''))),
|
|
'consent_id' => $consentId !== '' ? trim((string) explode(':', $consentId)[0]) : '',
|
|
'consent_id_raw' => $consentId,
|
|
'consent_request_id' => $consentRequestId,
|
|
'abha_address' => $abhaAddress,
|
|
'status' => $phase,
|
|
'purpose' => $purpose !== '' ? $purpose : 'Care Management',
|
|
'requested_hi_types' => $requestedHiTypes,
|
|
'granted_hi_types' => $grantedHiTypes,
|
|
'valid_from' => $validFrom,
|
|
'valid_to' => $validTo,
|
|
'erase_at' => $eraseAt,
|
|
'requested_from' => $requestedFrom,
|
|
'requested_to' => $requestedTo,
|
|
'requested_erase_at' => $requestedEraseAt,
|
|
'granted_from' => $grantedFrom,
|
|
'granted_to' => $grantedTo,
|
|
'granted_erase_at' => $grantedEraseAt,
|
|
'is_date_from_modified' => $isDateFromModified,
|
|
'is_date_to_modified' => $isDateToModified,
|
|
'is_date_range_modified' => ($isDateFromModified || $isDateToModified),
|
|
'is_erase_at_modified' => $isEraseAtModified,
|
|
'requested_on' => $requestedOn,
|
|
'granted_on' => $grantedOn,
|
|
'revoked_on' => $revokedOn,
|
|
'expired_on' => $expiredOn,
|
|
'hfr_id' => $hfrId,
|
|
'requested_by' => $requestedBy !== '' ? $requestedBy : 'HMS',
|
|
'artifacts' => $sessionArtifacts,
|
|
'items' => $items,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Normalizes a hiTypes value (JSON string, array, or single string) into a clean,
|
|
* de-duplicated string array.
|
|
*
|
|
* @param mixed $value
|
|
*/
|
|
private function normalizeHiTypesList($value): array
|
|
{
|
|
if (is_string($value)) {
|
|
$decoded = json_decode($value, true);
|
|
$value = is_array($decoded) ? $decoded : [$value];
|
|
}
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($value as $v) {
|
|
$v = trim((string) $v);
|
|
if ($v !== '' && ! in_array($v, $out, true)) {
|
|
$out[] = $v;
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
public function patient_file_list(int $pid)
|
|
{
|
|
if ($pid <= 0 || ! $this->db->tableExists('file_upload_data')) {
|
|
return $this->response->setJSON(['ok' => 0, 'files' => []]);
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('file_upload_data');
|
|
$safeSelect = ['id', 'full_path', 'public_path', 'file_ext', 'file_type', 'insert_date', 'document_type', 'scan_type', 'content_description', 'upload_by'];
|
|
$select = array_intersect($safeSelect, $fields);
|
|
|
|
$builder = $this->db->table('file_upload_data')
|
|
->select(implode(',', $select))
|
|
->where('pid', $pid);
|
|
|
|
if (in_array('show_type', $fields, true)) {
|
|
$builder->where('show_type', 0);
|
|
}
|
|
|
|
$rows = $builder->orderBy('id', 'DESC')->get()->getResultArray();
|
|
$files = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$rawPath = str_replace('\\', '/', (string) ($row['full_path'] ?? ''));
|
|
$publicPath = trim((string) ($row['public_path'] ?? ''));
|
|
if ($publicPath === '') {
|
|
$publicPath = $rawPath;
|
|
$pos = strpos($rawPath, '/uploads/');
|
|
if ($pos !== false) {
|
|
$publicPath = substr($rawPath, $pos);
|
|
}
|
|
}
|
|
$publicPath = str_replace('\\', '/', $publicPath);
|
|
if (strpos($publicPath, '/uploads/') !== 0 && stripos($publicPath, 'uploads/') === 0) {
|
|
$publicPath = '/' . ltrim($publicPath, '/');
|
|
}
|
|
|
|
$ext = strtolower((string) ($row['file_ext'] ?? pathinfo($publicPath, PATHINFO_EXTENSION)));
|
|
$ext = ltrim($ext, '.');
|
|
$title = trim((string) ($row['document_type'] ?? $row['scan_type'] ?? $row['content_description'] ?? ''));
|
|
if ($title === '' || strcasecmp($title, 'Queued for AI analysis') === 0) {
|
|
$title = 'Scanned/Uploaded Document';
|
|
}
|
|
|
|
$files[] = [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'title' => $title,
|
|
'path' => $publicPath,
|
|
'is_pdf' => $ext === 'pdf',
|
|
'ext' => $ext,
|
|
'uploaded_by' => (string) ($row['upload_by'] ?? ''),
|
|
'insert_date' => ! empty($row['insert_date']) ? date('d-m-Y H:i', strtotime((string) $row['insert_date'])) : '',
|
|
];
|
|
}
|
|
|
|
if ($this->request->isAJAX() || $this->request->getGet('json') === '1') {
|
|
return $this->response->setJSON(['ok' => 1, 'files' => $files]);
|
|
}
|
|
|
|
return view('billing/patient_file_list_partial', ['files' => $files, 'pid' => $pid]);
|
|
}
|
|
|
|
public function upload_patient_doc(int $pid)
|
|
{
|
|
if ($pid <= 0 || ! $this->db->tableExists('patient_master')) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid patient ID']);
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pid)->get(1)->getRowArray();
|
|
if (empty($patientRow)) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Patient not found']);
|
|
}
|
|
|
|
$uploadedFile = $this->request->getFile('userfile');
|
|
if (! $uploadedFile || ! $uploadedFile->isValid() || $uploadedFile->hasMoved()) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Please select a valid PDF or image file to upload.']);
|
|
}
|
|
|
|
$ext = strtolower((string) ($uploadedFile->getClientExtension() ?: $uploadedFile->guessExtension() ?: pathinfo((string) $uploadedFile->getName(), PATHINFO_EXTENSION)));
|
|
if (! in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'webp'], true)) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid file format. Upload PDF, JPG, PNG, or WEBP only.']);
|
|
}
|
|
|
|
$uploadsDir = rtrim(FCPATH, '/\\') . '/uploads/' . date('Ymd');
|
|
if (! is_dir($uploadsDir)) {
|
|
@mkdir($uploadsDir, 0777, true);
|
|
}
|
|
|
|
$baseFilename = 'PATIENT-' . $pid . '-' . time();
|
|
$filename = $baseFilename . '.' . $ext;
|
|
$fullPath = $uploadsDir . '/' . $filename;
|
|
$publicPath = '/uploads/' . date('Ymd') . '/' . $filename;
|
|
|
|
try {
|
|
$uploadedFile->move($uploadsDir, $filename);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Failed to save file: ' . $e->getMessage()]);
|
|
}
|
|
|
|
$user = auth()->user();
|
|
$userId = (int) ($user->id ?? 0);
|
|
$userName = (string) ($user->username ?? $user->email ?? 'User');
|
|
$mimeType = (string) ($uploadedFile->getClientMimeType() ?: ($ext === 'pdf' ? 'application/pdf' : 'image/jpeg'));
|
|
$docType = trim((string) ($this->request->getPost('document_type') ?? $this->request->getPost('title') ?? 'Patient Document'));
|
|
|
|
$insert = [
|
|
'name' => $filename,
|
|
'file_name' => $filename,
|
|
'orig_name' => (string) $uploadedFile->getName(),
|
|
'client_name' => (string) $uploadedFile->getName(),
|
|
'file_ext' => '.' . $ext,
|
|
'file_type' => $mimeType,
|
|
'full_path' => str_replace('\\', '/', $fullPath),
|
|
'file_path' => str_replace('\\', '/', $uploadsDir) . '/',
|
|
'raw_name' => $baseFilename,
|
|
'file_size' => @filesize($fullPath) ? round((float) filesize($fullPath) / 1024, 2) : 0,
|
|
'is_image' => $ext === 'pdf' ? 0 : 1,
|
|
'insert_date' => date('Y-m-d H:i:s'),
|
|
'upload_by' => $userName,
|
|
'upload_by_id' => $userId,
|
|
'pid' => $pid,
|
|
'opd_id' => 0,
|
|
'ipd_id' => 0,
|
|
'case_id' => 0,
|
|
'show_type' => 0,
|
|
'public_path' => $publicPath,
|
|
'document_type' => $docType,
|
|
'content_description' => $docType,
|
|
];
|
|
|
|
$fields = $this->db->getFieldNames('file_upload_data');
|
|
$insert = array_intersect_key($insert, array_flip($fields));
|
|
$this->db->table('file_upload_data')->insert($insert);
|
|
$fileId = (int) $this->db->insertID();
|
|
|
|
$fhirQueued = false;
|
|
if ($fileId > 0) {
|
|
try {
|
|
$fhirQueued = $this->enqueueHealthDocumentFhirForFileUpload($fileId);
|
|
} catch (\Throwable $e) {
|
|
log_message('warning', 'Unable to enqueue ABDM HealthDocumentRecord FHIR for patient file {id}: {msg}', ['id' => $fileId, 'msg' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'file_id' => $fileId,
|
|
'filename' => $filename,
|
|
'public_path' => $publicPath,
|
|
'fhir_queued' => $fhirQueued ? 1 : 0,
|
|
'message' => 'Document uploaded successfully' . ($fhirQueued ? ' and enqueued for ABDM Health Document sharing' : ''),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
public function delete_patient_doc(int $fileId)
|
|
{
|
|
if ($fileId <= 0 || ! $this->db->tableExists('file_upload_data')) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'File not found']);
|
|
}
|
|
|
|
$row = $this->db->table('file_upload_data')->where('id', $fileId)->get(1)->getRowArray();
|
|
if (empty($row)) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'File record not found']);
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('file_upload_data');
|
|
if (in_array('show_type', $fields, true)) {
|
|
$this->db->table('file_upload_data')->where('id', $fileId)->update(['show_type' => 1]);
|
|
} else {
|
|
$this->db->table('file_upload_data')->where('id', $fileId)->delete();
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'pid' => (int) ($row['pid'] ?? 0),
|
|
'message' => 'Document deleted successfully',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
public function enqueueHealthDocumentFhirForFileUpload(int $fileUploadId): bool
|
|
{
|
|
if ($fileUploadId <= 0 || ! $this->db->tableExists('file_upload_data')) {
|
|
return false;
|
|
}
|
|
|
|
$fileRow = $this->db->table('file_upload_data')->where('id', $fileUploadId)->get(1)->getRowArray();
|
|
if (empty($fileRow)) {
|
|
return false;
|
|
}
|
|
|
|
$pid = (int) ($fileRow['pid'] ?? 0);
|
|
if ($pid <= 0 && ! empty($fileRow['opd_id']) && $this->db->tableExists('opd_master')) {
|
|
$opdRow = $this->db->table('opd_master')->where('opd_id', (int) $fileRow['opd_id'])->get(1)->getRowArray();
|
|
$pid = (int) ($opdRow['p_id'] ?? 0);
|
|
}
|
|
if ($pid <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$patientRow = $this->db->table('patient_master')->where('id', $pid)->get(1)->getRowArray();
|
|
if (empty($patientRow)) {
|
|
return false;
|
|
}
|
|
|
|
$filePath = (string) ($fileRow['full_path'] ?? '');
|
|
if (! is_file($filePath) || ! is_readable($filePath)) {
|
|
$filePath = FCPATH . ltrim((string) ($fileRow['public_path'] ?? ''), '/\\');
|
|
}
|
|
|
|
if (! is_file($filePath) || ! is_readable($filePath)) {
|
|
return false;
|
|
}
|
|
|
|
$bytes = @file_get_contents($filePath);
|
|
if ($bytes === false || $bytes === '') {
|
|
return false;
|
|
}
|
|
|
|
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
|
$mimeType = match ($ext) {
|
|
'pdf' => 'application/pdf',
|
|
'png' => 'image/png',
|
|
'webp' => 'image/webp',
|
|
'jpg', 'jpeg' => 'image/jpeg',
|
|
default => 'application/octet-stream',
|
|
};
|
|
|
|
$docTitle = trim((string) ($fileRow['document_type'] ?? $fileRow['scan_type'] ?? $fileRow['content_description'] ?? ''));
|
|
if ($docTitle === '' || strcasecmp($docTitle, 'Queued for AI analysis') === 0) {
|
|
$docTitle = 'Patient Scanned Document';
|
|
}
|
|
|
|
$patientName = trim((string) ($patientRow['p_fname'] ?? ''));
|
|
$abhaIdRaw = '';
|
|
foreach (['abha_id', 'abha_no', 'abha'] as $field) {
|
|
$candidate = trim((string) ($patientRow[$field] ?? ''));
|
|
if ($candidate !== '') {
|
|
$abhaIdRaw = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
$abhaDigits = preg_replace('/\D/', '', $abhaIdRaw);
|
|
$abhaDigits = is_string($abhaDigits) ? $abhaDigits : '';
|
|
$abhaAddress = trim((string) ($patientRow['abha_address'] ?? ''));
|
|
|
|
$hfrId = trim(hospital_setting_value('ABDM_HFR_ID', 'HFR-IN-HMS'));
|
|
if ($hfrId === '') {
|
|
$hfrId = 'HFR-IN-HMS';
|
|
}
|
|
$hospitalName = defined('H_Name') ? (string) constant('H_Name') : 'Hospital';
|
|
|
|
$visitDate = ! empty($fileRow['insert_date']) ? date('Y-m-d', strtotime((string) $fileRow['insert_date'])) : date('Y-m-d');
|
|
$completedAt = ! empty($fileRow['insert_date']) ? date(DATE_ATOM, strtotime((string) $fileRow['insert_date'])) : date(DATE_ATOM);
|
|
|
|
$source = [
|
|
'record_id' => 'file-' . $fileUploadId,
|
|
'session_id' => (string) ($fileRow['opd_id'] ?? $fileUploadId),
|
|
'visit_date' => $visitDate,
|
|
'completed_at' => $completedAt,
|
|
'document_title' => $docTitle,
|
|
'document_data_base64' => base64_encode($bytes),
|
|
'content_type' => $mimeType,
|
|
'hfr_id' => $hfrId,
|
|
'organization' => [
|
|
'id' => $hfrId,
|
|
'name' => $hospitalName,
|
|
],
|
|
'patient' => [
|
|
'id' => (string) $pid,
|
|
'uhid' => (string) ($patientRow['uhid_no'] ?? $patientRow['uhid'] ?? $patientRow['patient_code'] ?? $pid),
|
|
'name' => $patientName,
|
|
'gender' => strtolower((string) ($patientRow['gender'] ?? '')) === 'm' ? 'male' : (strtolower((string) ($patientRow['gender'] ?? '')) === 'f' ? 'female' : 'unknown'),
|
|
'dob' => ! empty($patientRow['dob']) ? date('Y-m-d', strtotime((string) $patientRow['dob'])) : '',
|
|
'abha_id' => $abhaDigits,
|
|
'abha_address' => $abhaAddress,
|
|
'mobile' => (string) ($patientRow['mphone1'] ?? ''),
|
|
],
|
|
'practitioner' => [
|
|
'id' => '1',
|
|
'name' => 'Doctor',
|
|
],
|
|
];
|
|
|
|
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
|
|
$generatorOutput = $factory->healthDocument()->generate($source);
|
|
|
|
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
|
|
$gatewayPayload = $adapter->toGatewayPayload($generatorOutput, $source, $hfrId);
|
|
|
|
if ($pid > 0 && preg_match('/^\d{14}$/', $abhaDigits) === 1 && class_exists('\App\Libraries\AbdmWorkTaskService')) {
|
|
try {
|
|
$taskService = new \App\Libraries\AbdmWorkTaskService();
|
|
$taskService->createOrRefreshTask(
|
|
'health_document_publish',
|
|
'file_upload_data',
|
|
'patient_document',
|
|
(string) $fileUploadId,
|
|
$pid,
|
|
$patientName,
|
|
$abhaDigits,
|
|
'submit',
|
|
[
|
|
'file_upload_id' => $fileUploadId,
|
|
'clinical_timestamp' => date('Y-m-d H:i:s'),
|
|
'trigger' => 'file_upload_data.created',
|
|
]
|
|
);
|
|
} catch (\Throwable $e) {
|
|
log_message('warning', 'Unable to refresh ABDM task for file {id}: {msg}', ['id' => $fileUploadId, 'msg' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
$syncPayload = [
|
|
'local_record_id' => 'file-upload-' . $fileUploadId,
|
|
'local_patient_id' => $pid,
|
|
'hi_type' => 'HealthDocumentRecord',
|
|
'care_context_reference' => (string) ($gatewayPayload['care_context_reference'] ?? ('DOC-FILE-' . $fileUploadId)),
|
|
'care_context_display' => (string) ($gatewayPayload['care_context_display'] ?? ($docTitle . ' ' . $visitDate)),
|
|
'visit_date' => $visitDate,
|
|
'department' => '',
|
|
'doctor_name' => 'Doctor',
|
|
'consent_id' => '',
|
|
'hfr_id' => $hfrId,
|
|
'source_updated_at' => date('Y-m-d H:i:s'),
|
|
'patient_name' => $patientName,
|
|
'mobile' => (string) ($patientRow['mphone1'] ?? ''),
|
|
'gender' => (string) ($patientRow['gender'] ?? ''),
|
|
'dob' => (string) ($patientRow['dob'] ?? ''),
|
|
'abha_id' => $abhaDigits,
|
|
'abha_address' => $abhaAddress,
|
|
'fhir_bundle' => (array) ($gatewayPayload['fhir_bundle'] ?? []),
|
|
];
|
|
|
|
$outbox = new \App\Libraries\Abdm\Sync\AbdmSyncOutboxService();
|
|
$outbox->enqueueRecordSync($syncPayload);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function generate_fhir_for_file(int $fileId)
|
|
{
|
|
if ($fileId <= 0) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid file ID']);
|
|
}
|
|
|
|
$ok = $this->enqueueHealthDocumentFhirForFileUpload($fileId);
|
|
if (! $ok) {
|
|
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => 'Unable to generate FHIR HealthDocumentRecord for file #' . $fileId]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'file_id' => $fileId,
|
|
'message' => 'HealthDocumentRecord FHIR bundle generated & enqueued for ABDM sync',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
public function generate_fhir_bundle_for_all_files(int $pno)
|
|
{
|
|
if ($pno <= 0 || ! $this->db->tableExists('file_upload_data')) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid patient ID or file_upload_data table missing']);
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('file_upload_data') ?? [];
|
|
$builder = $this->db->table('file_upload_data');
|
|
if (in_array('pid', $fields, true)) {
|
|
$builder->where('pid', $pno);
|
|
}
|
|
if (in_array('show_type', $fields, true)) {
|
|
$builder->where('show_type', 0);
|
|
}
|
|
|
|
$rows = $builder->select('id')->get()->getResultArray();
|
|
$count = 0;
|
|
foreach ($rows as $row) {
|
|
$fileId = (int) ($row['id'] ?? 0);
|
|
if ($fileId > 0 && $this->enqueueHealthDocumentFhirForFileUpload($fileId)) {
|
|
$count++;
|
|
}
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'count' => $count,
|
|
'message' => 'Generated FHIR HealthDocumentRecord bundles for ' . $count . ' scanned document(s)',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
public function enqueueWellnessRecordFhir(int $patientId, int $opdSessionId = 0): bool
|
|
{
|
|
if ($patientId <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$docCtrl = new \App\Controllers\DoctorDocument();
|
|
$source = $docCtrl->buildWellnessRecordSource($patientId, $opdSessionId);
|
|
if (empty($source)) {
|
|
return false;
|
|
}
|
|
|
|
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
|
|
$generatorOutput = $factory->wellness()->generate($source);
|
|
|
|
$hfrId = (string) ($source['hfr_id'] ?? 'HFR-IN-HMS');
|
|
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
|
|
$gatewayPayload = $adapter->toGatewayPayload($generatorOutput, $source, $hfrId);
|
|
|
|
$patientName = (string) ($source['patient']['name'] ?? 'Patient');
|
|
$abhaDigits = (string) ($source['patient']['abha_id'] ?? '');
|
|
|
|
if ($patientId > 0 && preg_match('/^\d{14}$/', $abhaDigits) === 1 && class_exists('\App\Libraries\AbdmWorkTaskService')) {
|
|
try {
|
|
$taskService = new \App\Libraries\AbdmWorkTaskService();
|
|
$taskService->createOrRefreshTask(
|
|
'wellness_record_publish',
|
|
'opd_prescription',
|
|
'opd_vitals',
|
|
(string) ($source['record_id'] ?? $patientId),
|
|
$patientId,
|
|
$patientName,
|
|
$abhaDigits,
|
|
'submit',
|
|
[
|
|
'opd_session_id' => $opdSessionId,
|
|
'clinical_timestamp' => (string) ($source['visit_date'] ?? date('Y-m-d H:i:s')),
|
|
'trigger' => 'vitals.saved',
|
|
]
|
|
);
|
|
} catch (\Throwable $e) {
|
|
log_message('warning', 'Unable to refresh ABDM wellness task for patient {id}: {msg}', ['id' => $patientId, 'msg' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
$syncPayload = [
|
|
'local_record_id' => 'wellness-' . $patientId . ($opdSessionId > 0 ? ('-' . $opdSessionId) : ''),
|
|
'local_patient_id' => $patientId,
|
|
'hi_type' => 'WellnessRecord',
|
|
'care_context_reference' => (string) ($gatewayPayload['care_context_reference'] ?? ('WELLNESS-' . $patientId)),
|
|
'care_context_display' => (string) ($gatewayPayload['care_context_display'] ?? ('Wellness Record ' . date('d/m/Y'))),
|
|
'visit_date' => (string) ($source['visit_date'] ?? date('Y-m-d')),
|
|
'department' => '',
|
|
'doctor_name' => (string) ($source['doctor_name'] ?? 'Doctor'),
|
|
'consent_id' => '',
|
|
'hfr_id' => $hfrId,
|
|
'source_updated_at' => date('Y-m-d H:i:s'),
|
|
'patient_name' => $patientName,
|
|
'mobile' => (string) ($source['patient']['mobile'] ?? ''),
|
|
'gender' => (string) ($source['patient']['gender'] ?? ''),
|
|
'dob' => (string) ($source['patient']['dob'] ?? ''),
|
|
'abha_id' => $abhaDigits,
|
|
'abha_address' => (string) ($source['patient']['abha_address'] ?? ''),
|
|
'fhir_bundle' => (array) ($gatewayPayload['fhir_bundle'] ?? []),
|
|
];
|
|
|
|
$outbox = new \App\Libraries\Abdm\Sync\AbdmSyncOutboxService();
|
|
$outbox->enqueueRecordSync($syncPayload);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function generate_wellness_fhir(int $patientId, int $opdSessionId = 0)
|
|
{
|
|
if ($patientId <= 0) {
|
|
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid patient ID']);
|
|
}
|
|
|
|
$ok = $this->enqueueWellnessRecordFhir($patientId, $opdSessionId);
|
|
if (! $ok) {
|
|
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => 'Unable to generate WellnessRecord FHIR bundle for patient #' . $patientId]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => 1,
|
|
'patient_id' => $patientId,
|
|
'message' => 'WellnessRecord FHIR bundle generated & enqueued for ABDM sync',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
private function countAbdmDocumentsForPatient(int $pno, string $consentRequestRef = ''): int
|
|
{
|
|
if (! $this->db->tableExists('abdm_hiu_documents')) {
|
|
return 0;
|
|
}
|
|
|
|
$builder = $this->db->table('abdm_hiu_documents')->where('patient_id', $pno);
|
|
if ($consentRequestRef !== '') {
|
|
$relatedIds = $this->resolveAllRelatedConsentIds($consentRequestRef);
|
|
if ($relatedIds !== []) {
|
|
$fields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
|
|
$builder->groupStart();
|
|
$hasOr = false;
|
|
if (in_array('consent_request_id', $fields, true)) {
|
|
$builder->whereIn('consent_request_id', $relatedIds);
|
|
$hasOr = true;
|
|
}
|
|
if (in_array('consent_artifact_id', $fields, true)) {
|
|
if ($hasOr) {
|
|
$builder->orWhereIn('consent_artifact_id', $relatedIds);
|
|
} else {
|
|
$builder->whereIn('consent_artifact_id', $relatedIds);
|
|
$hasOr = true;
|
|
}
|
|
}
|
|
if (in_array('consent_ref', $fields, true)) {
|
|
if ($hasOr) {
|
|
$builder->orWhereIn('consent_ref', $relatedIds);
|
|
} else {
|
|
$builder->whereIn('consent_ref', $relatedIds);
|
|
$hasOr = true;
|
|
}
|
|
}
|
|
$builder->groupEnd();
|
|
}
|
|
}
|
|
|
|
return (int) $builder->countAllResults();
|
|
}
|
|
|
|
/**
|
|
* Resolves the complete set of identifiers associated with a given consent
|
|
* reference (umbrella consent_request_id, individual facility consent_artifact_ids,
|
|
* and internal workflow IDs).
|
|
*
|
|
* A multi-facility ABDM M3 consent session produces ONE umbrella
|
|
* consent_request_id, but ONE artifact_id PER linked HIP facility (e.g. one
|
|
* for external entities like Trio Hospital, and another for the same entity
|
|
* like DevSoft Tech). Documents in abdm_hiu_documents can be stored under either
|
|
* the umbrella request id or a specific facility's artifact id.
|
|
*
|
|
* This helper expands any single identifier bidirectionally so queries
|
|
* reliably match documents from BOTH external and same-entity facilities.
|
|
*
|
|
* @return string[]
|
|
*/
|
|
private function resolveAllRelatedConsentIds(string $ref): array
|
|
{
|
|
$ref = trim($ref);
|
|
if ($ref === '') {
|
|
return [];
|
|
}
|
|
|
|
$ids = [$ref];
|
|
|
|
if (ctype_digit($ref) && $this->db->tableExists('abdm_hiu_workflows')) {
|
|
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
|
|
$selCols = ['id'];
|
|
foreach (['abdm_consent_request_id', 'abdm_consent_artifact_id', 'consent_id', 'request_id', 'gateway_request_id'] as $sc) {
|
|
if (in_array($sc, $fields, true)) {
|
|
$selCols[] = $sc;
|
|
}
|
|
}
|
|
$anchorRow = $this->db->table('abdm_hiu_workflows')->select(implode(', ', $selCols))->where('id', (int) $ref)->get(1)->getRowArray();
|
|
if (is_array($anchorRow)) {
|
|
foreach ($selCols as $col) {
|
|
if ($col !== 'id') {
|
|
$val = trim((string) ($anchorRow[$col] ?? ''));
|
|
if ($val !== '' && ! preg_match('/^REQ-/i', $val)) {
|
|
$ids[] = $val;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (strpos($ref, ':') !== false) {
|
|
$parts = explode(':', $ref);
|
|
foreach ($parts as $p) {
|
|
$p = trim($p);
|
|
if ($p !== '') {
|
|
$ids[] = $p;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($this->db->tableExists('abdm_hiu_consent_artifacts')) {
|
|
$parentRows = $this->db->table('abdm_hiu_consent_artifacts')
|
|
->select('consent_request_id')
|
|
->whereIn('artifact_id', $ids)
|
|
->get()
|
|
->getResultArray();
|
|
foreach ($parentRows as $pr) {
|
|
$pId = trim((string) ($pr['consent_request_id'] ?? ''));
|
|
if ($pId !== '') {
|
|
$ids[] = $pId;
|
|
}
|
|
}
|
|
|
|
$siblingRows = $this->db->table('abdm_hiu_consent_artifacts')
|
|
->select('artifact_id, consent_request_id')
|
|
->whereIn('consent_request_id', $ids)
|
|
->get()
|
|
->getResultArray();
|
|
foreach ($siblingRows as $sr) {
|
|
$aId = trim((string) ($sr['artifact_id'] ?? ''));
|
|
$cId = trim((string) ($sr['consent_request_id'] ?? ''));
|
|
if ($aId !== '') {
|
|
$ids[] = $aId;
|
|
}
|
|
if ($cId !== '') {
|
|
$ids[] = $cId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($this->db->tableExists('abdm_hiu_workflows')) {
|
|
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
|
|
$matchCols = array_intersect(['abdm_consent_request_id', 'abdm_consent_artifact_id', 'consent_id', 'request_id', 'gateway_request_id'], $fields);
|
|
if (! empty($matchCols)) {
|
|
$wfBuilder = $this->db->table('abdm_hiu_workflows')
|
|
->select(implode(', ', $matchCols));
|
|
$wfBuilder->groupStart();
|
|
$first = true;
|
|
foreach ($matchCols as $col) {
|
|
if ($first) {
|
|
$wfBuilder->whereIn($col, $ids);
|
|
$first = false;
|
|
} else {
|
|
$wfBuilder->orWhereIn($col, $ids);
|
|
}
|
|
}
|
|
$wfBuilder->groupEnd();
|
|
$wfRows = $wfBuilder->limit(50)->get()->getResultArray();
|
|
foreach ($wfRows as $wfr) {
|
|
foreach ($matchCols as $col) {
|
|
$val = trim((string) ($wfr[$col] ?? ''));
|
|
if ($val !== '' && ! preg_match('/^REQ-/i', $val)) {
|
|
$ids[] = $val;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique(array_filter($ids)));
|
|
}
|
|
|
|
private function triggerBackgroundHiuPoll(): void
|
|
{
|
|
try {
|
|
$sparkPath = ROOTPATH . 'spark';
|
|
if (DIRECTORY_SEPARATOR === '\\') {
|
|
$cmd = 'start /B php "' . $sparkPath . '" abdm:hiu-poll --limit=10 > NUL 2>&1';
|
|
@pclose(@popen($cmd, 'r'));
|
|
} else {
|
|
$phpBin = is_executable('/usr/bin/php8.3') ? '/usr/bin/php8.3' : (is_executable('/usr/bin/php') ? '/usr/bin/php' : 'php');
|
|
$cmd = $phpBin . ' "' . $sparkPath . '" abdm:hiu-poll --limit=10 > /dev/null 2>&1 &';
|
|
@exec($cmd);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('warning', '[triggerBackgroundHiuPoll] ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts dataEraseAt timestamp from a workflow row's request_json or response_json.
|
|
*/
|
|
private function extractDataEraseAtFromWorkflowRow(array $row): string
|
|
{
|
|
if (! empty($row['expired_at'])) {
|
|
return (string) $row['expired_at'];
|
|
}
|
|
$req = json_decode((string) ($row['request_json'] ?? ''), true);
|
|
$respStr = (string) ($row['response_json'] ?? '');
|
|
$resp = (strlen($respStr) > 0 && strlen($respStr) <= 65536) ? json_decode($respStr, true) : null;
|
|
$containers = [
|
|
is_array($req) ? ($req['consent'] ?? null) : null,
|
|
is_array($req) ? ($req['consentDetail'] ?? null) : null,
|
|
is_array($req) ? ($req['data']['consent'] ?? null) : null,
|
|
is_array($req) ? $req : null,
|
|
is_array($resp) ? ($resp['consent'] ?? null) : null,
|
|
is_array($resp) ? ($resp['consentDetail'] ?? null) : null,
|
|
is_array($resp) ? ($resp['data']['consent'] ?? null) : null,
|
|
is_array($resp) ? $resp : null,
|
|
];
|
|
foreach ($containers as $c) {
|
|
if (! is_array($c)) {
|
|
continue;
|
|
}
|
|
$erase = trim((string) (
|
|
$c['permission']['dataEraseAt']
|
|
?? $c['permission']['data_erase_at']
|
|
?? (is_array($c['expiry'] ?? null) ? ($c['expiry']['date'] ?? null) : ($c['expiry'] ?? null))
|
|
?? $c['erase_at']
|
|
?? $c['eraseAt']
|
|
?? $c['dataEraseAt']
|
|
?? $c['data_erase_at']
|
|
?? ''
|
|
));
|
|
if ($erase !== '') {
|
|
return $erase;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Deletes cached health records from abdm_hiu_documents for the given consent request
|
|
* or artifact references, complying with ABDM M3 dataEraseAt guidelines.
|
|
*/
|
|
private function purgeAbdmDocumentsByRefs(array $refs): int
|
|
{
|
|
$refs = array_values(array_unique(array_filter(array_map('trim', $refs))));
|
|
if ($refs === [] || ! $this->db->tableExists('abdm_hiu_documents')) {
|
|
return 0;
|
|
}
|
|
$docFields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
|
|
$builder = $this->db->table('abdm_hiu_documents');
|
|
$builder->groupStart();
|
|
$hasClause = false;
|
|
if (in_array('consent_request_id', $docFields, true)) {
|
|
$builder->whereIn('consent_request_id', $refs);
|
|
$hasClause = true;
|
|
}
|
|
if (in_array('consent_artifact_id', $docFields, true)) {
|
|
if ($hasClause) {
|
|
$builder->orWhereIn('consent_artifact_id', $refs);
|
|
} else {
|
|
$builder->whereIn('consent_artifact_id', $refs);
|
|
$hasClause = true;
|
|
}
|
|
}
|
|
if (in_array('consent_ref', $docFields, true)) {
|
|
if ($hasClause) {
|
|
$builder->orWhereIn('consent_ref', $refs);
|
|
} else {
|
|
$builder->whereIn('consent_ref', $refs);
|
|
$hasClause = true;
|
|
}
|
|
}
|
|
$builder->groupEnd();
|
|
if (! $hasClause) {
|
|
return 0;
|
|
}
|
|
$builder->delete();
|
|
return (int) $this->db->affectedRows();
|
|
}
|
|
}
|
|
|
|
|