devsofttech_hms_ci4/app/Controllers/Home.php

997 lines
39 KiB
PHP

<?php
namespace App\Controllers;
use CodeIgniter\Shield\Models\UserModel;
class Home extends BaseController
{
private const USER_PROFILE_PHOTO_DIR = 'assets/images/user_profile';
private function ensureAuthenticated()
{
if (function_exists('auth') && auth()->loggedIn()) {
return null;
}
if ($this->request->isAJAX()) {
return $this->response->setStatusCode(401)->setBody('Unauthorized');
}
return redirect()->to(base_url('login'));
}
public function index()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $authRedirect;
}
return view('welcome_message', [
'initial_route' => base_url('dashboard'),
'initial_title' => 'Dashboard',
]);
}
public function accessDenied()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $authRedirect;
}
return $this->response
->setStatusCode(403)
->setBody(view('errors/html/access_denied'));
}
public function billing()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $authRedirect;
}
$user = service('auth')->user();
return view('billing/main', ['user' => $user]);
}
public function dashboard()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $authRedirect;
}
if (! $this->request->isAJAX()) {
return view('welcome_message', [
'initial_route' => base_url('dashboard'),
'initial_title' => 'Dashboard',
]);
}
$user = service('auth')->user();
$today = date('Y-m-d');
$monthStart = date('Y-m-01');
$todayStart = $today . ' 00:00:00';
$tomorrowStart = date('Y-m-d', strtotime('+1 day')) . ' 00:00:00';
$monthStartDateTime = $monthStart . ' 00:00:00';
$opdToday = $this->db->table('opd_master')
->where('apointment_date >=', $todayStart)
->where('apointment_date <', $tomorrowStart)
->countAllResults();
$opdLast7Days = $this->db->table('opd_master')
->where('apointment_date >=', date('Y-m-d', strtotime('-7 days')) . ' 00:00:00')
->where('apointment_date <', $tomorrowStart)
->countAllResults();
$admitToday = $this->db->table('ipd_master')
->where('register_date', $today)
->countAllResults();
$dischargeToday = $this->db->table('ipd_master')
->where('discharge_date', $today)
->where('ipd_status', 1)
->countAllResults();
$currentIpd = $this->db->table('ipd_master')
->where('ipd_status', 0)
->countAllResults();
$currentOrgIpd = $this->db->table('ipd_master i')
->join('organization_case_master o', 'o.id = i.case_id', 'inner')
->where('o.case_type', 1)
->where('i.ipd_status', 0)
->countAllResults();
$diagnosisToday = $this->db->table('invoice_item ii')
->join('invoice_master im', 'im.id = ii.inv_master_id', 'inner')
->whereIn('ii.item_type', [1, 2, 3, 4, 5, 6, 30])
->where('im.invoice_status', 1)
->where('im.inv_date >=', $today)
->where('im.inv_date <', date('Y-m-d', strtotime('+1 day')))
->countAllResults();
$diagnosisCompletedToday = $this->db->table('invoice_item ii')
->join('invoice_master im', 'im.id = ii.inv_master_id', 'inner')
->join('lab_request lr', 'lr.charge_item_id = ii.id AND lr.charge_id = im.id', 'left')
->whereIn('ii.item_type', [1, 2, 3, 4, 5, 6, 30])
->where('im.invoice_status', 1)
->where('im.inv_date >=', $today)
->where('im.inv_date <', date('Y-m-d', strtotime('+1 day')))
->where('lr.status', 2)
->countAllResults();
$opdOrgList = $this->db->table('opd_master o')
->select('ins.short_name as org_name')
->select('COUNT(o.opd_id) as total_cases', false)
->join('hc_insurance ins', 'ins.id = o.insurance_id', 'left')
->where('o.apointment_date >=', $monthStartDateTime)
->where('o.apointment_date <', $tomorrowStart)
->groupBy('ins.short_name')
->orderBy('total_cases', 'DESC')
->limit(10)
->get()
->getResult();
$opdDoctorList = $this->db->table('opd_master o')
->select('o.doc_name')
->select('COUNT(o.opd_id) as total_cases', false)
->select("SUM(CASE WHEN (o.insurance_id IS NULL OR o.insurance_id <= 1) THEN 1 ELSE 0 END) as direct_cases", false)
->select("SUM(CASE WHEN (o.insurance_id IS NULL OR o.insurance_id <= 1) THEN 0 ELSE 1 END) as org_cases", false)
->where('o.apointment_date >=', $todayStart)
->where('o.apointment_date <', $tomorrowStart)
->where('o.doc_name IS NOT NULL', null, false)
->where("o.doc_name != ''", null, false)
->groupBy('o.doc_name')
->orderBy('total_cases', 'DESC')
->limit(12)
->get()
->getResult();
$ipdDoctorList = $this->db->table('ipd_master_doc_list l')
->select('d.p_fname as doc_name')
->select('COUNT(DISTINCT l.ipd_id) as total_cases', false)
->join('doctor_master d', 'd.id = l.doc_id', 'inner')
->join('ipd_master i', 'i.id = l.ipd_id', 'inner')
->where('i.ipd_status', 0)
->groupBy('d.p_fname')
->orderBy('total_cases', 'DESC')
->limit(12)
->get()
->getResult();
$ipdOrgList = $this->db->table('organization_case_master o')
->select('ins.short_name as org_name')
->select('COUNT(o.id) as total_cases', false)
->join('ipd_master i', 'o.id = i.case_id', 'inner')
->join('hc_insurance ins', 'ins.id = o.insurance_id', 'left')
->where('o.case_type', 1)
->where('i.ipd_status', 0)
->groupBy('ins.short_name')
->orderBy('total_cases', 'DESC')
->limit(10)
->get()
->getResult();
$trendStart = $monthStart; // First day of current month
$trendEnd = $today;
$trendDays = [];
// Generate all days from start of month to today
$startTime = strtotime($monthStart);
$endTime = strtotime($today);
$currentTime = $startTime;
while ($currentTime <= $endTime) {
$trendDays[] = date('Y-m-d', $currentTime);
$currentTime = strtotime('+1 day', $currentTime);
}
$opdTrendRaw = $this->db->table('opd_master')
->select("DATE_FORMAT(apointment_date, '%Y-%m-%d') as yd", false)
->select('COUNT(opd_id) as total', false)
->where('apointment_date >=', $trendStart . ' 00:00:00')
->where('apointment_date <', $tomorrowStart)
->groupBy('yd')
->get()
->getResult();
$ipdTrendRaw = $this->db->table('ipd_master')
->select("DATE_FORMAT(register_date, '%Y-%m-%d') as yd", false)
->select('COUNT(id) as total', false)
->where('register_date >=', $trendStart)
->where('register_date <=', $trendEnd)
->groupBy('yd')
->get()
->getResult();
$opdTrendMap = [];
foreach ($opdTrendRaw as $row) {
$opdTrendMap[$row->yd] = (int) $row->total;
}
$ipdTrendMap = [];
foreach ($ipdTrendRaw as $row) {
$ipdTrendMap[$row->yd] = (int) $row->total;
}
$opdTrend = [];
$ipdTrend = [];
$trendLabels = [];
foreach ($trendDays as $dayKey) {
$trendLabels[] = date('d M', strtotime($dayKey));
$opdTrend[] = $opdTrendMap[$dayKey] ?? 0;
$ipdTrend[] = $ipdTrendMap[$dayKey] ?? 0;
}
$data = [
'user' => $user,
'opd_today' => $opdToday,
'opd_last_7_days' => $opdLast7Days,
'admit_today' => $admitToday,
'discharge_today' => $dischargeToday,
'current_ipd' => $currentIpd,
'current_org_ipd' => $currentOrgIpd,
'diagnosis_today' => $diagnosisToday,
'diagnosis_completed_today' => $diagnosisCompletedToday,
'opd_org_list' => $opdOrgList,
'opd_doctor_list' => $opdDoctorList,
'ipd_doctor_list' => $ipdDoctorList,
'ipd_org_list' => $ipdOrgList,
'trend_labels' => $trendLabels,
'trend_opd' => $opdTrend,
'trend_ipd' => $ipdTrend,
'month_start' => $monthStart,
];
return view('dashboard/index', $data);
}
public function opdDashboard()
{
if (($response = $this->dashboardAccessResponse('dashboard/opd', 'OPD Dashboard')) !== null) {
return $response;
}
[$fromDate, $toDate, $fromDateTime, $toExclusive] = $this->dashboardDateRange();
$base = $this->db->table('opd_master')
->where('apointment_date >=', $fromDateTime)
->where('apointment_date <', $toExclusive);
$totals = $this->db->table('opd_master')
->select('COUNT(opd_id) total_visits, COUNT(DISTINCT p_id) unique_patients', false)
->select('SUM(CASE WHEN opd_status = 2 THEN 1 ELSE 0 END) completed_visits', false)
->select('SUM(CASE WHEN opd_status = 3 THEN 1 ELSE 0 END) cancelled_visits', false)
->where('apointment_date >=', $fromDateTime)
->where('apointment_date <', $toExclusive)
->get()->getRowArray() ?? [];
$doctorRows = (clone $base)
->select("COALESCE(NULLIF(doc_name, ''), 'Unassigned') label", false)
->select('COUNT(opd_id) total, COUNT(DISTINCT p_id) patients', false)
->groupBy('label')->orderBy('total', 'DESC')->get()->getResultArray();
$departmentRows = (clone $base)
->select("COALESCE(NULLIF(doc_spec, ''), 'Unassigned') label", false)
->select('COUNT(opd_id) total, COUNT(DISTINCT p_id) patients', false)
->groupBy('label')->orderBy('total', 'DESC')->get()->getResultArray();
$organizationRows = (clone $base)
->select("CASE WHEN o.insurance_id IS NULL OR o.insurance_id <= 1 THEN 'Direct' ELSE COALESCE(NULLIF(ins.short_name, ''), 'Organization') END label", false)
->select('COUNT(o.opd_id) total', false)
->join('hc_insurance ins', 'ins.id = o.insurance_id', 'left')
->from('opd_master o', true)
->groupBy('label')->orderBy('total', 'DESC')->get()->getResultArray();
$referralRows = $this->db->query(
"SELECT COALESCE(NULLIF(CONCAT_WS(' ', NULLIF(r.title, ''), NULLIF(r.f_name, '')), ''), NULLIF(x.refer_name, ''), 'Not recorded') label,
COUNT(o.opd_id) total, COUNT(DISTINCT o.p_id) patients
FROM opd_master o
LEFT JOIN (
SELECT opd_code, MAX(NULLIF(refer_by_id, 0)) refer_id,
MAX(NULLIF(NULLIF(refer_by_other, ''), '0')) refer_name
FROM invoice_master GROUP BY opd_code
) x ON x.opd_code = o.opd_code
LEFT JOIN refer_master r ON r.id = x.refer_id
WHERE o.apointment_date >= ? AND o.apointment_date < ?
GROUP BY label ORDER BY total DESC",
[$fromDateTime, $toExclusive]
)->getResultArray();
return view('dashboard/opd', compact('fromDate', 'toDate', 'totals', 'doctorRows', 'departmentRows', 'organizationRows', 'referralRows'));
}
public function ipdDashboard()
{
if (($response = $this->dashboardAccessResponse('dashboard/ipd', 'IPD Dashboard')) !== null) {
return $response;
}
[$fromDate, $toDate] = $this->dashboardDateRange();
$totals = $this->db->table('ipd_master')
->select('COUNT(id) admissions', false)
->select('SUM(CASE WHEN ipd_status = 0 THEN 1 ELSE 0 END) active_cases', false)
->where('register_date >=', $fromDate)->where('register_date <=', $toDate)
->get()->getRowArray() ?? [];
$totals['discharges'] = $this->db->table('ipd_master')
->where('ipd_status', 1)->where('discharge_date >=', $fromDate)->where('discharge_date <=', $toDate)
->countAllResults();
$totals['current_in_house'] = $this->db->table('ipd_master')->where('ipd_status', 0)->countAllResults();
$departmentRows = $this->db->table('ipd_master i')
->select("COALESCE(NULLIF(d.vName, ''), 'Unassigned') label", false)->select('COUNT(i.id) total', false)
->join('hc_department d', 'd.iId = i.dept_id', 'left')
->where('i.register_date >=', $fromDate)->where('i.register_date <=', $toDate)
->groupBy('label')->orderBy('total', 'DESC')->get()->getResultArray();
$doctorRows = $this->db->table('ipd_master i')
->select("COALESCE(NULLIF(d.p_fname, ''), NULLIF(i.r_doc_name, ''), 'Unassigned') label", false)
->select('COUNT(DISTINCT i.id) total', false)
->join('ipd_master_doc_list l', 'l.ipd_id = i.id', 'left')
->join('doctor_master d', 'd.id = l.doc_id', 'left')
->where('i.register_date >=', $fromDate)->where('i.register_date <=', $toDate)
->groupBy('label')->orderBy('total', 'DESC')->get()->getResultArray();
$referralRows = $this->db->table('ipd_master i')
->select("COALESCE(NULLIF(CONCAT_WS(' ', NULLIF(r.title, ''), NULLIF(r.f_name, '')), ''), 'Not recorded') label", false)
->select('COUNT(i.id) total', false)
->join('refer_master r', 'r.id = i.refer_by', 'left')
->where('i.register_date >=', $fromDate)->where('i.register_date <=', $toDate)
->groupBy('label')->orderBy('total', 'DESC')->get()->getResultArray();
$organizationRows = $this->db->table('ipd_master i')
->select("CASE WHEN i.case_id IS NULL OR i.case_id = 0 THEN 'Direct' ELSE COALESCE(NULLIF(ins.short_name, ''), 'Organization') END label", false)
->select('COUNT(i.id) total', false)
->join('organization_case_master oc', 'oc.id = i.case_id', 'left')
->join('hc_insurance ins', 'ins.id = oc.insurance_id', 'left')
->where('i.register_date >=', $fromDate)->where('i.register_date <=', $toDate)
->groupBy('label')->orderBy('total', 'DESC')->get()->getResultArray();
return view('dashboard/ipd', compact('fromDate', 'toDate', 'totals', 'doctorRows', 'departmentRows', 'organizationRows', 'referralRows'));
}
public function diagnosisDashboard()
{
if (($response = $this->dashboardAccessResponse('dashboard/diagnosis', 'Diagnosis Dashboard')) !== null) {
return $response;
}
[$fromDate, $toDate] = $this->dashboardDateRange();
$modalityNames = [1 => 'Ultrasound', 2 => 'MRI', 3 => 'X-Ray', 4 => 'CT Scan', 5 => 'Pathology', 6 => 'Echo', 30 => 'Biopsy'];
$rows = $this->db->table('invoice_item ii')
->select('ii.item_type modality_id')->select('COUNT(DISTINCT ii.id) total', false)
->select('SUM(CASE WHEN lr.id IS NOT NULL THEN 1 ELSE 0 END) samples_received', false)
->select('SUM(CASE WHEN lr.id IS NULL THEN 1 ELSE 0 END) pending_collection', false)
->select('SUM(CASE WHEN lr.id IS NOT NULL AND IFNULL(lr.status, 0) < 2 THEN 1 ELSE 0 END) pending_reports', false)
->select('SUM(CASE WHEN lr.status = 2 THEN 1 ELSE 0 END) completed_reports', false)
->join('invoice_master im', 'im.id = ii.inv_master_id', 'inner')
->join('lab_request lr', 'lr.charge_item_id = ii.id AND lr.charge_id = im.id', 'left')
->whereIn('ii.item_type', array_keys($modalityNames))->where('im.invoice_status', 1)
->where('im.inv_date >=', $fromDate)->where('im.inv_date <=', $toDate)
->groupBy('ii.item_type')->get()->getResultArray();
$modalityRows = [];
foreach ($modalityNames as $id => $name) {
$match = array_values(array_filter($rows, static fn (array $row): bool => (int) $row['modality_id'] === $id))[0] ?? [];
$modalityRows[] = array_merge(['modality_id' => $id, 'label' => $name, 'total' => 0, 'samples_received' => 0, 'pending_collection' => 0, 'pending_reports' => 0, 'completed_reports' => 0], $match);
}
$totals = ['total' => 0, 'samples_received' => 0, 'pending_collection' => 0, 'pending_reports' => 0, 'completed_reports' => 0];
foreach ($modalityRows as $row) {
foreach (array_keys($totals) as $key) {
$totals[$key] += (int) $row[$key];
}
}
return view('dashboard/diagnosis', compact('fromDate', 'toDate', 'totals', 'modalityRows'));
}
private function dashboardAccessResponse(string $route, string $title)
{
if (($authRedirect = $this->ensureAuthenticated()) !== null) {
return $authRedirect;
}
if (! $this->request->isAJAX()) {
return view('welcome_message', ['initial_route' => base_url($route), 'initial_title' => $title]);
}
return null;
}
/** @return array{0:string,1:string,2:string,3:string} */
private function dashboardDateRange(): array
{
$today = date('Y-m-d');
$fromDate = (string) ($this->request->getGet('from_date') ?: $today);
$toDate = (string) ($this->request->getGet('to_date') ?: $fromDate);
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $fromDate) || strtotime($fromDate) === false) {
$fromDate = $today;
}
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $toDate) || strtotime($toDate) === false) {
$toDate = $fromDate;
}
if ($fromDate > $toDate) {
[$fromDate, $toDate] = [$toDate, $fromDate];
}
return [$fromDate, $toDate, $fromDate . ' 00:00:00', date('Y-m-d', strtotime($toDate . ' +1 day')) . ' 00:00:00'];
}
public function myProfile()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $authRedirect;
}
if (! $this->request->isAJAX()) {
return view('welcome_message', [
'initial_route' => base_url('my-profile'),
'initial_title' => 'My Profile',
]);
}
$user = service('auth')->user();
$userId = (int) ($user->id ?? 0);
$meta = $this->getCurrentUserProfileMeta($userId);
// Load user settings (use hospital defaults if not set)
$userSettingsModel = model('UserSettings');
$sidebarAutoHideSecondsUser = $userSettingsModel->getUserSetting(
$userId,
'SIDEBAR_AUTO_HIDE_SECONDS',
hospital_setting_value('SIDEBAR_AUTO_HIDE_SECONDS', '7')
);
return view('account/my_profile', [
'user' => $user,
'phone_no' => $meta['phone_no'] ?? '',
'person_name' => $meta['full_name'] ?? '',
'profile_photo_url' => $this->buildUserProfilePhotoUrl((string) ($meta['profile_photo'] ?? '')),
'sidebar_auto_hide_seconds' => $sidebarAutoHideSecondsUser,
]);
}
public function speechTest()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $authRedirect;
}
return view('tools/speech_test');
}
/**
* Server-side proxy for the STT service.
* Forwards audio from HTTPS clients to the HTTP STT server,
* avoiding browser mixed-content blocks.
*/
public function sttProxy()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $this->response->setStatusCode(401)->setJSON(['error' => 'Unauthorized']);
}
$file = $this->request->getFile('audio');
if ($file === null) {
// Fallback key for compatibility with alternate STT clients.
$file = $this->request->getFile('file');
}
if ($file === null) {
return $this->response->setStatusCode(400)->setJSON([
'error' => 'No audio file received',
'expected_field' => 'audio',
]);
}
if (! $file->isValid()) {
return $this->response->setStatusCode(400)->setJSON([
'error' => 'Invalid uploaded audio file',
'upload_error_code' => $file->getError(),
'upload_error' => $file->getErrorString(),
'upload_max_filesize' => (string) ini_get('upload_max_filesize'),
'post_max_size' => (string) ini_get('post_max_size'),
]);
}
$fileSize = (int) $file->getSize();
if ($fileSize <= 0) {
return $this->response->setStatusCode(400)->setJSON([
'error' => 'Empty audio received',
]);
}
$lang = (string) ($this->request->getPost('lang') ?? 'en-IN');
$context = (string) ($this->request->getPost('medical_context') ?? '');
$sttUrl = 'http://139.59.13.39:8000/stt/transcribe';
$tmpPath = $file->getTempName();
$mime = $file->getMimeType() ?: 'audio/webm';
$name = $file->getName() ?: 'audio.webm';
$ch = curl_init($sttUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_POSTFIELDS => [
'audio' => new \CURLFile($tmpPath, $mime, $name),
'lang' => $lang,
'medical_context' => $context,
],
]);
$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($body === false || $curlErr !== '') {
return $this->response->setStatusCode(502)
->setJSON(['error' => 'STT server unreachable', 'detail' => $curlErr]);
}
$decoded = json_decode((string) $body, true);
if ($decoded === null) {
return $this->response->setStatusCode(502)
->setJSON(['error' => 'Invalid response from STT server']);
}
return $this->response->setStatusCode($httpCode > 0 ? $httpCode : 200)
->setContentType('application/json')
->setBody($body);
}
/**
* Proxy health check — pings the STT server and returns its status.
* Lets the browser check availability over HTTPS without mixed-content issues.
*/
public function sttProxyHealth()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $this->response->setStatusCode(401)->setJSON(['ok' => false]);
}
$ch = curl_init('http://139.59.13.39:8000/health');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 4,
CURLOPT_NOBODY => false,
]);
$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$ok = ($body !== false && $httpCode >= 200 && $httpCode < 300);
return $this->response->setStatusCode(200)->setJSON(['ok' => $ok]);
}
public function myProfileSave()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $authRedirect;
}
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON([
'update' => 0,
'error_text' => 'Invalid request',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$authUser = service('auth')->user();
$userId = (int) ($authUser->id ?? 0);
if ($userId <= 0) {
return $this->response->setStatusCode(401)->setJSON([
'update' => 0,
'error_text' => 'Unauthorized',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$email = trim((string) $this->request->getPost('email'));
$personName = trim((string) $this->request->getPost('person_name'));
$phoneNo = trim((string) $this->request->getPost('phone_no'));
$password = (string) $this->request->getPost('password');
$passwordConfirm = (string) $this->request->getPost('password_confirm');
$metaBeforeUpdate = $this->getCurrentUserProfileMeta($userId);
$profilePhotoFile = $this->request->getFile('profile_photo');
if ($profilePhotoFile !== null && $profilePhotoFile->getError() !== UPLOAD_ERR_NO_FILE && ! $profilePhotoFile->isValid()) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Invalid profile photo upload.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$photoProvided = $profilePhotoFile !== null
&& $profilePhotoFile->isValid()
&& $profilePhotoFile->getError() !== UPLOAD_ERR_NO_FILE;
$profilePhotoFileName = '';
if ($photoProvided) {
$photoSize = (int) $profilePhotoFile->getSize();
$photoMime = strtolower((string) $profilePhotoFile->getMimeType());
$allowedMime = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
];
if ($photoSize <= 0 || $photoSize > (2 * 1024 * 1024)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Profile photo must be less than 2MB.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if (! in_array($photoMime, $allowedMime, true)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Profile photo must be JPG, PNG, or WEBP.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$extension = strtolower((string) $profilePhotoFile->getExtension());
if ($extension === '') {
$extension = $photoMime === 'image/png' ? 'png' : ($photoMime === 'image/webp' ? 'webp' : 'jpg');
}
$profilePhotoFileName = 'user_' . $userId . '_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
}
if ($personName !== '' && mb_strlen($personName) > 120) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Person name must be maximum 120 characters.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Valid email is required.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if ($phoneNo !== '' && preg_match('/^[0-9+\-()\s]{7,20}$/', $phoneNo) !== 1) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Phone format is invalid.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if ($password !== '' || $passwordConfirm !== '') {
if (strlen($password) < 8) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Password must be at least 8 characters.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if (! hash_equals($password, $passwordConfirm)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Password and confirm password do not match.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
}
$tables = config('Auth')->tables;
$identitiesTable = (string) ($tables['identities'] ?? 'auth_identities');
$duplicateEmail = $this->db->table($identitiesTable)
->select('id')
->where('type', 'email_password')
->where('secret', $email)
->where('user_id !=', $userId)
->get(1)
->getRowArray();
if (! empty($duplicateEmail)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Email already used by another user.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$userModel = model(UserModel::class);
$user = $userModel->find($userId);
if ($user === null) {
return $this->response->setStatusCode(404)->setJSON([
'update' => 0,
'error_text' => 'User not found',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$user->email = $email;
if ($password !== '') {
$user->password = $password;
}
if (! $userModel->save($user)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Unable to update profile.',
'errors' => $userModel->errors(),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if ($photoProvided) {
$uploadDir = FCPATH . self::USER_PROFILE_PHOTO_DIR;
if (! is_dir($uploadDir) && ! @mkdir($uploadDir, 0755, true) && ! is_dir($uploadDir)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Unable to create profile photo directory.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
try {
$profilePhotoFile->move($uploadDir, $profilePhotoFileName, true);
} catch (\Throwable $e) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Unable to upload profile photo.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$oldPhoto = trim((string) ($metaBeforeUpdate['profile_photo'] ?? ''));
if ($oldPhoto !== '' && $oldPhoto !== $profilePhotoFileName) {
$oldPhotoPath = $uploadDir . DIRECTORY_SEPARATOR . basename($oldPhoto);
if (is_file($oldPhotoPath)) {
@unlink($oldPhotoPath);
}
}
}
$this->setCurrentUserProfileMeta(
$userId,
$phoneNo,
$personName,
$photoProvided ? $profilePhotoFileName : null
);
// Save user settings
$sidebarAutoHideSeconds = (int) trim((string) $this->request->getPost('sidebar_auto_hide_seconds'));
if ($sidebarAutoHideSeconds < 0) {
$sidebarAutoHideSeconds = 0;
}
if ($sidebarAutoHideSeconds > 60) {
$sidebarAutoHideSeconds = 60;
}
$userSettingsModel = model('UserSettings');
if ($sidebarAutoHideSeconds > 0) {
$userSettingsModel->setUserSetting($userId, 'SIDEBAR_AUTO_HIDE_SECONDS', (string) $sidebarAutoHideSeconds);
} else {
// Delete if zero to use hospital default
$userSettingsModel->deleteUserSetting($userId, 'SIDEBAR_AUTO_HIDE_SECONDS');
}
$metaAfterUpdate = $this->getCurrentUserProfileMeta($userId);
$displayName = trim((string) ($user->username ?? ''));
if ($personName !== '') {
$displayName = $personName;
} elseif ($displayName === '') {
$displayName = trim((string) ($email !== '' ? $email : 'User'));
}
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Profile updated successfully.',
'display_name' => $displayName,
'person_name' => $personName,
'login_id' => trim((string) ($user->username ?? '')),
'user_id' => $userId,
'email' => $email,
'phone_no' => $phoneNo,
'profile_photo_url' => $this->buildUserProfilePhotoUrl((string) ($metaAfterUpdate['profile_photo'] ?? '')),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function myProfileSaveSettings()
{
if (! $this->request->is('post')) {
return $this->response->setStatusCode(405)->setJSON([
'success' => 0,
'message' => 'Method not allowed.',
]);
}
$authUser = auth()->user();
if (! $authUser) {
return $this->response->setStatusCode(401)->setJSON([
'success' => 0,
'message' => 'Not authenticated.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$userId = (int) $authUser->id;
// Process sidebar auto-hide setting
$sidebarAutoHideSecondsInput = trim((string) $this->request->getPost('sidebar_auto_hide_seconds'));
$userSettingsModel = model('UserSettings');
if ($sidebarAutoHideSecondsInput === '') {
// Empty means use hospital default
$userSettingsModel->deleteUserSetting($userId, 'SIDEBAR_AUTO_HIDE_SECONDS');
$message = 'Settings saved. Using hospital default.';
} else {
$sidebarAutoHideSeconds = (int) $sidebarAutoHideSecondsInput;
if ($sidebarAutoHideSeconds < 0) {
$sidebarAutoHideSeconds = 0;
}
if ($sidebarAutoHideSeconds > 60) {
$sidebarAutoHideSeconds = 60;
}
if ($sidebarAutoHideSeconds > 0) {
$userSettingsModel->setUserSetting($userId, 'SIDEBAR_AUTO_HIDE_SECONDS', (string) $sidebarAutoHideSeconds);
$message = "Sidebar auto-hide set to {$sidebarAutoHideSeconds} seconds.";
} else {
// 0 means disable auto-hide
$userSettingsModel->setUserSetting($userId, 'SIDEBAR_AUTO_HIDE_SECONDS', '0');
$message = 'Sidebar auto-hide disabled.';
}
}
return $this->response->setJSON([
'success' => 1,
'message' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
/**
* @return array{phone_no:string, full_name:string, profile_photo:string}
*/
private function getCurrentUserProfileMeta(int $userId): array
{
if ($userId <= 0) {
return ['phone_no' => '', 'full_name' => '', 'profile_photo' => ''];
}
$tables = config('Auth')->tables;
$identitiesTable = (string) ($tables['identities'] ?? 'auth_identities');
if (! $this->db->tableExists($identitiesTable)) {
return ['phone_no' => '', 'full_name' => '', 'profile_photo' => ''];
}
$row = $this->db->table($identitiesTable)
->select('extra')
->where('user_id', $userId)
->where('type', 'email_password')
->get(1)
->getRowArray();
$extraRaw = trim((string) ($row['extra'] ?? ''));
if ($extraRaw === '') {
return ['phone_no' => '', 'full_name' => '', 'profile_photo' => ''];
}
$decoded = json_decode($extraRaw, true);
if (! is_array($decoded)) {
return ['phone_no' => '', 'full_name' => '', 'profile_photo' => ''];
}
return [
'phone_no' => trim((string) ($decoded['phone_no'] ?? '')),
'full_name' => trim((string) ($decoded['full_name'] ?? '')),
'profile_photo' => trim((string) ($decoded['profile_photo'] ?? '')),
];
}
private function setCurrentUserProfileMeta(int $userId, string $phoneNo, string $fullName, ?string $profilePhoto = null): void
{
if ($userId <= 0) {
return;
}
$tables = config('Auth')->tables;
$identitiesTable = (string) ($tables['identities'] ?? 'auth_identities');
if (! $this->db->tableExists($identitiesTable)) {
return;
}
$row = $this->db->table($identitiesTable)
->select('id,extra')
->where('user_id', $userId)
->where('type', 'email_password')
->get(1)
->getRowArray();
if (empty($row) || empty($row['id'])) {
return;
}
$payload = [];
$raw = trim((string) ($row['extra'] ?? ''));
if ($raw !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
$payload = $decoded;
}
}
if ($phoneNo === '') {
unset($payload['phone_no']);
} else {
$payload['phone_no'] = $phoneNo;
}
if ($fullName === '') {
unset($payload['full_name']);
} else {
$payload['full_name'] = $fullName;
}
if ($profilePhoto !== null) {
$profilePhoto = trim($profilePhoto);
if ($profilePhoto === '') {
unset($payload['profile_photo']);
} else {
$payload['profile_photo'] = basename($profilePhoto);
}
}
$this->db->table($identitiesTable)
->where('id', (int) $row['id'])
->update([
'extra' => empty($payload) ? null : json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
}
private function buildUserProfilePhotoUrl(string $photoFileName): string
{
$photoFileName = trim($photoFileName);
if ($photoFileName === '') {
return base_url('assets/img/profile-img.jpg');
}
$absolutePath = FCPATH . self::USER_PROFILE_PHOTO_DIR . DIRECTORY_SEPARATOR . basename($photoFileName);
if (! is_file($absolutePath)) {
return base_url('assets/img/profile-img.jpg');
}
return base_url(self::USER_PROFILE_PHOTO_DIR . '/' . basename($photoFileName));
}
}