devsofttech_hms_ci4/app/Controllers/Patient.php
DevSoftTech Server 67daaf8894 Fix ABDM consent modal showing all HI types as DENIED when granted via async data push
When a consent session's overall phase resolves to GRANTED/COMPLETED via a data_fetch or hi_data_push_callback row (proof data was actually fetched), but no consent_reconcile/consent_status row in that session ever recorded which hi_types were granted (e.g. data arrives via async push before any reconcile poll runs), grantedHiTypes stayed empty and every requested HI type was incorrectly marked DENIED. Fall back to treating the full requested set as granted when we have positive proof of GRANTED/COMPLETED but no negative evidence of per-type denial.
2026-07-29 01:44:17 +05:30

4067 lines
134 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()
{
$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();
}
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);
}
$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),
'udai' => strtoupper((string) $this->request->getPost('input_udai')),
'estimate_dob' => $estimate_dob,
];
$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->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'));
$inputRelativeName = trim((string) $this->request->getPost('input_relative_name'));
$inputName = trim((string) $this->request->getPost('input_name'));
$abhaField = $this->resolvePatientAbhaIdField();
$builder = $this->db->table('patient_master');
$builder->select("id,p_fname,p_relative,p_rname,gender,add1,city,district,state,zip,mphone1,udai,last_visit,dob,age,age_in_month,estimate_dob");
if ($abhaField !== null) {
$builder->select($abhaField . ' AS abha_id');
}
$hasCondition = false;
if ($inputMphone1 !== '' || $inputAadhar !== '' || $inputAbhaId !== '' || ($inputRelativeName !== '' && $inputName !== '')) {
$builder->groupStart();
if ($inputMphone1 !== '') {
$builder->where('mphone1', $inputMphone1);
$hasCondition = true;
}
if ($inputAadhar !== '') {
$builder->orWhere('udai', $inputAadhar);
$hasCondition = true;
}
if ($inputAbhaId !== '' && $abhaField !== null) {
$builder->orWhere($abhaField, $inputAbhaId);
$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' => $inputAbhaId,
'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']);
}
$profile = [];
$abhaField = $this->resolvePatientAbhaIdField();
if ($abhaField !== null) {
$row = $this->db->table('patient_master')
->select('id,p_fname,gender,dob,mphone1,city,state')
->where($abhaField, $abhaId)
->get(1)
->getRowArray();
if (! empty($row)) {
$profile = [
'name' => (string) ($row['p_fname'] ?? ''),
'gender' => (string) ($row['gender'] ?? ''),
'dob' => (string) ($row['dob'] ?? ''),
'mobile' => (string) ($row['mphone1'] ?? ''),
'city' => (string) ($row['city'] ?? ''),
'state' => (string) ($row['state'] ?? ''),
];
}
}
$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,
'queue_id' => $queueId,
'profile' => $profile,
'source' => ! empty($profile) ? 'local_cache' : 'abdm_queue',
]);
}
// -------------------------------------------------------------------------
// 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 ?? ''));
$data['search_query'] = $sdata;
return view('billing/Patient_Search_V', $data);
}
public function search_ajax()
{
$request = $this->request->getGet();
// Get search value from DataTables search box or from initial search_query
$dtSearch = trim((string) ($request['search']['value'] ?? ''));
$initialSearch = trim((string) ($request['search_query'] ?? ''));
// Use DataTables search if provided, otherwise use initial search_query
$sdata = $dtSearch !== '' ? $dtSearch : $initialSearch;
$sdata = preg_replace('/[^A-Za-z0-9 _.@\-]/', '', $sdata);
// Detect ABHA column name in patient_master
$abhaField = null;
$pmFields = $this->db->getFieldNames('patient_master') ?? [];
foreach (['abha_id', 'abha_no', 'abha', 'abha_address'] as $f) {
if (in_array($f, $pmFields, true)) { $abhaField = $f; break; }
}
// Build WHERE clause based on search query
$searchString = ' 1=1 ';
if (strlen($sdata) > 0) {
$sdateArray = explode(' ', $sdata);
foreach ($sdateArray as $rowData) {
if ($rowData === '') {
continue;
}
if (is_numeric($rowData)) {
$abhaClause = $abhaField ? " or p.{$abhaField} = " . $this->db->escape($rowData) : '';
$searchString .= " and (p.p_code like " . $this->db->escape('%' . $rowData) . "
or p.mphone1 = " . $this->db->escape($rowData) . "
or p.udai=" . $this->db->escape($rowData) . $abhaClause . ")";
} elseif (ctype_alpha($rowData)) {
$searchString .= " and (p.p_fname like " . $this->db->escape('%' . $rowData . '%') . "
or p.email1 = " . $this->db->escape($rowData) . "
or SUBSTRING_INDEX(p.p_fname,' ',1) sounds like " . $this->db->escape($rowData) . ")";
} else {
// Handle dashed ABHA format: XX-XXXX-XXXX-XXXX
$rawDigits = preg_replace('/\D/', '', $rowData);
$abhaElse = ($abhaField && strlen($rawDigits) === 14)
? " or p.{$abhaField} = " . $this->db->escape($rawDigits) . " or p.{$abhaField} = " . $this->db->escape($rowData)
: '';
$searchString .= " and (p.p_code like " . $this->db->escape($rowData) . "
or p.email1 = " . $this->db->escape($rowData) . $abhaElse . ")";
}
}
}
// 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));
$data[] = [
$start + $index + 1,
'<a href="javascript:load_form(\'' . base_url('billing/patient/person_record/' . $patientId) . '\');">' . esc($row->p_code ?? '') . '</a>',
esc(($row->p_fname ?? '') . ' {' . ($row->p_rname ?? '') . '}'),
$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');
}
$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();
$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 (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;
}
} elseif ($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;
}
$data['profile_file_path'] = $profile_file_path;
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,
]);
}
$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')),
'udai' => strtoupper((string) $this->request->getPost('input_Aadhar')),
'estimate_dob' => $estimate_dob,
'blood_group' => $this->request->getPost('input_blood_group'),
];
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',
]);
}
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');
$patientModel = new PatientModel();
$patientModel->updatePatient(['udai' => $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.',
]);
}
// 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));
$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] ?? [],
];
}
return view('billing/Patient_Profile_Opd_V', [
'patient' => $patient,
'profileFilePath' => $profileFilePath,
'opdGroups' => $opdGroups,
'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') ?? 100);
if ($limit <= 0 || $limit > 300) {
$limit = 100;
}
$q = trim((string) ($this->request->getGet('q') ?? ''));
$builder = $this->db->table('abdm_hiu_documents d')
->select('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')
->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 ($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();
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',
]);
}
$row['summary'] = json_decode((string) ($row['summary_json'] ?? ''), true) ?: [];
return $this->response->setJSON([
'ok' => 1,
'item' => $row,
]);
}
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 {
$fromIst = $dateFromInput !== '' ? new \DateTimeImmutable($dateFromInput, $istTz) : $nowIst->modify('-365 days');
} catch (\Throwable $e) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'Invalid "Date From" date.',
]);
}
try {
$toIst = $dateToInput !== '' ? new \DateTimeImmutable($dateToInput, $istTz) : $nowIst;
} catch (\Throwable $e) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'Invalid "Date To" date.',
]);
}
if ($fromIst->getTimestamp() >= $toIst->getTimestamp()) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => '"Date From" must be earlier than "Date To".',
]);
}
try {
$eraseIst = $eraseDateInput !== '' ? new \DateTimeImmutable($eraseDateInput, $istTz) : $nowIst->modify('+1 year');
} catch (\Throwable $e) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'Invalid "Expiry Date" date.',
]);
}
if ($eraseIst->setTime(23, 59, 59)->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->setTime(0, 0, 0)->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->setTime(23, 59, 59)->setTimezone($utcTz)->format('Y-m-d\TH:i:s.000\Z');
$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,
],
],
];
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
$payload = [
'patient_id' => $pno,
'abha_address' => $abhaContext['abha_address'],
'consent' => $consent,
];
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,
]);
}
$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,
// continue directly with fetch instead of re-reconciling a stale request_id.
if ($resumedFromLastSync && strtoupper(trim((string) ($lastSync['phase'] ?? ''))) === 'GRANTED') {
if ($consentArtifactRef === '' && $consentRequestRef === '') {
$consentRequestRef = trim((string) ($lastSync['consent_request_id'] ?? ''));
$consentArtifactRef = trim((string) ($lastSync['consent_id'] ?? ''));
}
$canDirectFetch = ($consentArtifactRef !== '' && preg_match('/^REQ-/i', $consentArtifactRef) !== 1)
|| ($consentRequestRef !== '' && preg_match('/^REQ-/i', $consentRequestRef) !== 1);
if ($canDirectFetch) {
$fetchPayload = [
'abha_address' => $abhaContext['abha_address'],
];
if ($consentArtifactRef !== '' && preg_match('/^REQ-/i', $consentArtifactRef) !== 1) {
$fetchPayload['consentId'] = $consentArtifactRef;
} elseif ($consentRequestRef !== '' && preg_match('/^REQ-/i', $consentRequestRef) !== 1) {
$fetchPayload['consentRequestId'] = $consentRequestRef;
}
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(),
'request_id' => $flowRefId,
]);
}
if ((int) ($fetchResult['ok'] ?? 0) === 1) {
return $this->response->setJSON([
'ok' => 1,
'phase' => 'COMPLETED',
'poll_again' => 0,
'request_id' => $flowRefId,
'message' => 'Resumed previous granted consent flow and fetched data successfully.',
'data' => [
'consent_request' => $consentResult,
'consent_reconcile' => $reconcileResult,
'data_fetch' => $fetchResult,
],
]);
}
}
}
$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);
$isNonFatalPending = $httpCode === 404
&& (stripos($errorText, 'Consent record not found') !== false
|| stripos($errorText, 'NOT_FOUND') !== false);
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') {
$fetchPayload = [
'abha_address' => $abhaContext['abha_address'],
'consentId' => $consentArtifactRef,
];
if ($fetchPayload['consentId'] === '') {
unset($fetchPayload['consentId']);
if ($consentRequestRef !== '') {
$fetchPayload['consentRequestId'] = $consentRequestRef;
}
}
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 after fallback reconcile: ' . $e->getMessage(),
'request_id' => $flowRefId,
]);
}
if ((int) ($fetchResult['ok'] ?? 0) === 1) {
return $this->response->setJSON([
'ok' => 1,
'phase' => 'COMPLETED',
'poll_again' => 0,
'request_id' => $flowRefId,
'message' => 'Consent status was resolved using fallback bridge reference and data fetched successfully.',
'data' => [
'consent_request' => $consentResult,
'consent_reconcile' => $reconcileResult,
'data_fetch' => $fetchResult,
],
]);
}
}
}
}
$altConsentRequestRef = trim((string) ($consentRequestRef !== '' ? $consentRequestRef : ($lastSync['consent_request_id'] ?? '')));
$altConsentArtifactRef = trim((string) ($consentArtifactRef !== '' ? $consentArtifactRef : ($lastSync['consent_id'] ?? '')));
$canDirectFetch = ($altConsentArtifactRef !== '' && preg_match('/^REQ-/i', $altConsentArtifactRef) !== 1)
|| ($altConsentRequestRef !== '' && preg_match('/^REQ-/i', $altConsentRequestRef) !== 1);
if ($canDirectFetch) {
$fetchPayload = [
'abha_address' => $abhaContext['abha_address'],
];
if ($altConsentArtifactRef !== '' && preg_match('/^REQ-/i', $altConsentArtifactRef) !== 1) {
$fetchPayload['consentId'] = $altConsentArtifactRef;
} elseif ($altConsentRequestRef !== '' && preg_match('/^REQ-/i', $altConsentRequestRef) !== 1) {
$fetchPayload['consentRequestId'] = $altConsentRequestRef;
}
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(),
'request_id' => $flowRefId,
]);
}
if ((int) ($fetchResult['ok'] ?? 0) === 1) {
return $this->response->setJSON([
'ok' => 1,
'phase' => 'COMPLETED',
'poll_again' => 0,
'request_id' => $flowRefId,
'message' => 'Consent status lookup returned not found for old request id, but data fetch succeeded using saved consent reference.',
'data' => [
'consent_request' => $consentResult,
'consent_reconcile' => $reconcileResult,
'data_fetch' => $fetchResult,
],
]);
}
}
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') {
$fetchPayload = [
'abha_address' => $abhaContext['abha_address'],
];
if ($consentArtifactRef !== '') {
$fetchPayload['consentId'] = $consentArtifactRef;
} elseif ($consentRequestRef !== '') {
$fetchPayload['consentRequestId'] = $consentRequestRef;
} else {
$fetchPayload['request_id'] = $flowRefId;
}
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(),
'request_id' => $flowRefId,
]);
}
if ((int) ($fetchResult['ok'] ?? 0) !== 1) {
$httpCode = (int) ($fetchResult['http_code'] ?? 422);
if ($httpCode < 100 || $httpCode > 599) {
$httpCode = 422;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'phase' => 'FETCH_FAILED',
'error' => (string) ($fetchResult['error_text'] ?? 'Data fetch failed.'),
'request_id' => $flowRefId,
'data' => [
'consent_request' => $consentResult,
'consent_reconcile' => $reconcileResult,
'data_fetch' => $fetchResult,
],
]);
}
$state = 'COMPLETED';
$pollAgain = false;
}
$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')
?? ''
));
if ($overrideConsentId !== '' || $overrideConsentRequestId !== '') {
$phase = 'GRANTED';
$consentArtifactRef = $overrideConsentId;
$consentRequestRef = $overrideConsentRequestId;
} else {
$lastSync = $this->getLatestAbdmSyncSnapshot((string) ($abhaContext['abha_address'] ?? ''));
$phase = strtoupper(trim((string) ($lastSync['phase'] ?? '')));
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.',
]);
}
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
$fetchPayload = [
'abha_address' => $abhaContext['abha_address'],
];
if ($consentArtifactRef !== '') {
$fetchPayload['consentId'] = $consentArtifactRef;
} else {
$fetchPayload['consentRequestId'] = $consentRequestRef;
}
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;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'phase' => 'FETCH_FAILED',
'error' => (string) ($fetchResult['error_text'] ?? 'Data fetch failed.'),
'data' => ['data_fetch' => $fetchResult],
]);
}
return $this->response->setJSON([
'ok' => 1,
'phase' => 'COMPLETED',
'message' => 'Records fetched successfully using existing granted consent.',
'documents_persisted' => (int) ($fetchResult['documents_persisted'] ?? 0),
'documents_updated' => (int) ($fetchResult['documents_updated'] ?? 0),
'data' => ['data_fetch' => $fetchResult],
]);
}
/**
* 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.',
]);
}
return $this->response->setJSON($this->getAbdmConsentRequestsList($abhaAddress));
}
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()) {
$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;
$photoMeta = [
'saved' => false,
'path' => '',
'error' => '',
];
if ($patientId > 0 && $this->db->tableExists('patient_master')) {
$updates = [];
if ($abhaDigits !== '') {
$abhaField = $this->resolvePatientAbhaIdField();
if ($abhaField !== null) {
$updates[$abhaField] = $abhaDigits;
}
}
$pmFields = $this->db->getFieldNames('patient_master') ?? [];
if ($gatewayMobile !== '' && in_array('mphone1', $pmFields, true)) {
$updates['mphone1'] = preg_replace('/\D/', '', $gatewayMobile);
}
if ($gatewayAddress !== '' && in_array('add1', $pmFields, true)) {
$updates['add1'] = $gatewayAddress;
}
if ($gatewayCity !== '' && in_array('city', $pmFields, true)) {
$updates['city'] = $gatewayCity;
}
if ($gatewayDistrictName !== '' && in_array('district', $pmFields, true)) {
$updates['district'] = $gatewayDistrictName;
}
if ($gatewayStateName !== '' && in_array('state', $pmFields, true)) {
$updates['state'] = $gatewayStateName;
}
if ($gatewayZip !== '' && in_array('zip', $pmFields, true)) {
$updates['zip'] = $gatewayZip;
}
if ($gatewayEmail !== '' && in_array('email1', $pmFields, true)) {
$updates['email1'] = $gatewayEmail;
}
if ($verifiedStatus !== '' && in_array('abha_verified_status', $pmFields, true)) {
$updates['abha_verified_status'] = $verifiedStatus;
}
if ($verificationType !== '' && in_array('abha_verification_type', $pmFields, true)) {
$updates['abha_verification_type'] = $verificationType;
}
if ($kycVerified !== null && in_array('abha_kyc_verified', $pmFields, true)) {
$updates['abha_kyc_verified'] = (int) ((string) $kycVerified === '1' || (string) $kycVerified === 'true' || (string) $kycVerified === 'yes');
}
if ($mobileVerified !== null && in_array('abha_mobile_verified', $pmFields, true)) {
$updates['abha_mobile_verified'] = (int) ((string) $mobileVerified === '1' || (string) $mobileVerified === 'true' || (string) $mobileVerified === 'yes');
}
if ($abhaAddress !== '' && in_array('abha_address', $pmFields, true)) {
$updates['abha_address'] = $abhaAddress;
}
if (in_array('abdm_linked_at', $pmFields, true) && ($abhaDigits !== '' || $abhaAddress !== '')) {
$updates['abdm_linked_at'] = date('Y-m-d H:i:s');
}
if ($updates !== []) {
$this->db->table('patient_master')->where('id', $patientId)->update($updates);
}
if ($gatewayPhoto !== '') {
$photoMeta = $this->saveGatewayProfilePhotoToPatient($patientId, $gatewayPhoto);
}
}
$afterPatient = null;
if ($patientId > 0 && $this->db->tableExists('patient_master')) {
$afterPatient = $this->db->table('patient_master')->where('id', $patientId)->get()->getRowArray();
}
$after = $afterPatient !== null ? $this->buildPatientSnapshot($afterPatient) : $before;
$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' => $photoMeta,
];
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),
];
}
/**
* @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;
}
/**
* @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.
}
}
/**
* @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') ?? [];
$select = ['id', 'operation', 'workflow_state', 'status', 'request_id', 'consent_id', 'updated_at', 'last_error', 'http_code', 'response_json'];
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;
}
$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 = strtoupper(trim((string) (
$decoded['consent']['status']
?? $decoded['consent_status']
?? $decoded['status']
?? $decoded['data']['consent']['status']
?? ''
)));
$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;
if (($operation === 'DATA_FETCH' || $operation === 'HI_DATA_PUSH_CALLBACK') && $status === 'SUCCESS') {
$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'] ?? '');
// 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); the last one is the most recent.
return $this->computeConsentSessionDetail(end($sessions), $abhaAddress);
}
/**
* 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) {
$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') ?? [];
$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', 'response_json'];
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" — each CONSENT_REQUEST row starts a new session, and every
* subsequent row (status/reconcile/data_fetch/callback) belongs to that
* session until the next CONSENT_REQUEST row appears. Returns sessions in
* chronological order (oldest session first); rows within CONSENT_REQUEST
* are always the first row of their session.
*
* @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 = [];
$current = [];
foreach ($chronological as $row) {
$operation = strtoupper(trim((string) ($row['operation'] ?? '')));
if ($operation === 'CONSENT_REQUEST' && $current !== []) {
$sessions[] = $current;
$current = [];
}
$current[] = $row;
}
if ($current !== []) {
$sessions[] = $current;
}
return $sessions;
}
/**
* 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 = strtoupper(trim((string) (
$decoded['consent']['status']
?? $decoded['consent_status']
?? $decoded['status']
?? $decoded['data']['consent']['status']
?? ''
)));
$operation = strtoupper(trim((string) ($row['operation'] ?? '')));
$status = strtoupper(trim((string) ($row['status'] ?? '')));
$state = strtoupper(trim((string) ($row['workflow_state'] ?? '')));
$phase = 'REQUESTED';
$priority = 120;
if (($operation === 'DATA_FETCH' || $operation === 'HI_DATA_PUSH_CALLBACK') && $status === 'SUCCESS') {
$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 ($state === 'DATA_RECEIVED') {
$phase = 'COMPLETED';
$priority = 480;
} elseif ($state === 'GRANTED') {
$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'];
$consentId = trim((string) ($best['abdm_consent_artifact_id'] ?? $best['consent_id'] ?? $bestDecoded['consent_id'] ?? $bestDecoded['consentId'] ?? ''));
$consentRequestId = trim((string) ($best['abdm_consent_request_id'] ?? $bestDecoded['abdm_consent_request_id'] ?? $bestDecoded['consent_request_id'] ?? $bestDecoded['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;
}
}
$requestedHiTypes = [];
$requestedOn = '';
$purpose = '';
$validFrom = '';
$validTo = '';
$eraseAt = '';
$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'] ?? ''));
$validFrom = trim((string) ($consentBlock['permission']['dateRange']['from'] ?? ''));
$validTo = trim((string) ($consentBlock['permission']['dateRange']['to'] ?? ''));
$eraseAt = trim((string) ($consentBlock['permission']['dataEraseAt'] ?? ''));
$requestedBy = trim((string) ($consentBlock['requester']['name'] ?? ''));
if ($hfrId === '') {
$hfrId = trim((string) ($consentRequestRow['hfr_id'] ?? ''));
}
}
// Bridge flattens consent.hi_types -> top-level hi_types on consent_status /
// consent_reconcile / consent_callback responses (see M3HiuGatewayClient
// flatten logic). The "best" row for overall phase can be a data_fetch /
// hi_data_push_callback row (once records are COMPLETED) whose response is
// a FHIR data bundle with no hi_types field at all — so look separately for
// the most recent row (within this session) that actually carries the
// granted hi_types payload.
$grantedHiTypes = [];
$grantedOn = '';
foreach ($rows as $row) {
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
if (! is_array($decoded)) {
continue;
}
$rowHiTypes = $this->normalizeHiTypesList($decoded['hi_types'] ?? $decoded['consent']['hi_types'] ?? []);
if ($rowHiTypes !== []) {
$grantedHiTypes = $rowHiTypes;
$grantedOn = trim((string) ($decoded['granted_at'] ?? $row['updated_at'] ?? ''));
break;
}
}
// If this session's overall phase is GRANTED/COMPLETED but no row ever
// recorded WHICH hi_types were granted (e.g. data arrived via an async
// hi_data_push_callback before any consent_reconcile/consent_status poll
// ran and stored a hi_types-bearing response), do not mark every
// requested type as DENIED for lack of evidence — a COMPLETED/GRANTED
// phase is itself proof the consent covers at least the requested
// types, so fall back to treating the full requested set as granted.
if ($grantedHiTypes === [] && $requestedHiTypes !== [] && in_array($phase, ['GRANTED', 'COMPLETED'], true)) {
$grantedHiTypes = $requestedHiTypes;
}
$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'] ?? ''));
}
$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' => [
'consent_id' => $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_on' => $requestedOn,
'granted_on' => $grantedOn,
'revoked_on' => $revokedOn,
'expired_on' => $expiredOn,
'hfr_id' => $hfrId,
'requested_by' => $requestedBy !== '' ? $requestedBy : 'HMS',
'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;
}
}