diff --git a/app/Config/Routes.php b/app/Config/Routes.php index de6aa42..0c295b4 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -47,3 +47,8 @@ $routes->post('/check-email', 'Admin::checkEmail'); $routes->get('admin/doctors/data', 'Admin::getDoctors'); $routes->get('admin/patients/data', 'Admin::getPatients'); $routes->get('/admin/activity-log', 'ActivityLog::index'); +$routes->get('/admin/activity/analytics', 'ActivityLog::analytics'); +$routes->post('/admin/activity-log/clear', 'ActivityLog::clear'); +// $routes->post('/admin/activity-log/datatable', 'ActivityLog::datatable'); +// $routes->get('/admin/activity-log/summary', 'ActivityLog::getSummary'); +// $routes->get('/admin/activity-log/critical', 'ActivityLog::getCritical'); diff --git a/app/Controllers/ActivityLog.php b/app/Controllers/ActivityLog.php index 1f104c9..c4980ac 100644 --- a/app/Controllers/ActivityLog.php +++ b/app/Controllers/ActivityLog.php @@ -12,17 +12,111 @@ class ActivityLog extends BaseController return $r; } - $logModel = new ActivityLogModel(); - $filters = [ - 'action' => trim((string) $this->request->getGet('action')), - 'role' => trim((string) $this->request->getGet('role')), - 'date_from' => trim((string) $this->request->getGet('date_from')), - 'date_to' => trim((string) $this->request->getGet('date_to')), - ]; + $activityModel = new ActivityLogModel(); + + // Get filter parameters + $search = $this->request->getGet('search') ?? ''; + $action = $this->request->getGet('action') ?? ''; + $userType = $this->request->getGet('user_type') ?? ''; + $dateFrom = $this->request->getGet('date_from') ?? ''; + $dateTo = $this->request->getGet('date_to') ?? ''; + $ip = $this->request->getGet('ip') ?? ''; + + // Build filters array + $filters = []; + if (!empty($search)) $filters['search'] = $search; + if (!empty($action)) $filters['action'] = $action; + if (!empty($userType)) $filters['user_type'] = $userType; + if (!empty($dateFrom)) $filters['date_from'] = $dateFrom; + if (!empty($dateTo)) $filters['date_to'] = $dateTo; + if (!empty($ip)) $filters['ip'] = $ip; + + // Get filtered logs + $logs = $activityModel->getFilteredLogs($filters, 200); + + // Debug: Check database connection and count + $db = \Config\Database::connect(); + $totalInDb = $db->table('activity_logs')->countAll(); + log_message('debug', 'Activity logs in database: ' . $totalInDb . ', retrieved: ' . count($logs)); return view('admin/activity_log', [ - 'logs' => $logModel->getFiltered($filters), - 'filters' => $filters, + 'logs' => $logs, + 'totalLogs' => count($logs), + 'totalInDb' => $totalInDb, + 'actionSummary' => $activityModel->getActionSummary(), + 'roleSummary' => $activityModel->getRoleSummary(), + 'filters' => [ + 'search' => $search, + 'action' => $action, + 'user_type' => $userType, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'ip' => $ip, + ], + 'availableActions' => $activityModel->getAvailableActions(), + 'actionIcons' => [ + 'login' => 'box-arrow-in-right', + 'logout' => 'box-arrow-right', + 'register_patient' => 'person-plus', + 'register_doctor' => 'person-badge-plus', + 'add_doctor' => 'person-badge-plus', + 'update_doctor' => 'person-badge', + 'delete_doctor' => 'person-badge-x', + 'add_patient' => 'person-plus', + 'update_patient' => 'person', + 'delete_patient' => 'person-x', + 'book_appointment' => 'calendar-plus', + 'update_appointment' => 'calendar-check', + 'cancel_appointment' => 'calendar-x', + 'view_appointment' => 'calendar-event', + ], ]); } + + public function clear() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + if (strtolower($this->request->getMethod()) === 'post') { + $activityModel = new ActivityLogModel(); + if ($activityModel->clearAll()) { + return redirect()->to(base_url('admin/activity-log'))->with('success', 'All activity logs have been cleared.'); + } else { + return redirect()->to(base_url('admin/activity-log'))->with('error', 'Failed to clear activity logs.'); + } + } + + return redirect()->to(base_url('admin/activity-log')); + } + + public function analytics() + { + if ($r = $this->requireRole('admin')) { + return $r; + } + + $period = $this->request->getGet('period') ?? '7_days'; + if (! in_array($period, ['7_days', '30_days'], true)) { + $period = '7_days'; + } + + $activityModel = new ActivityLogModel(); + $summary = $activityModel->getSummary($period); + + return view('admin/activity_analytics', [ + 'period' => $period, + 'summary' => $summary, + 'actionLabels' => json_encode(array_keys($summary['by_action'])), + 'actionCounts' => json_encode(array_values($summary['by_action'])), + 'typeLabels' => json_encode(array_keys($summary['by_role'])), + 'typeCounts' => json_encode(array_values($summary['by_role'])), + 'userLabels' => json_encode(array_column($summary['most_active_users'], 'actor')), + 'userCounts' => json_encode(array_column($summary['most_active_users'], 'count')), + 'uniqueIPs' => $summary['unique_ips'], + 'criticalActions' => $activityModel->getCriticalActions(50), + ]); + } + } diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index 3ef184c..53e39e2 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -96,7 +96,10 @@ class Auth extends BaseController ]); $logModel = new ActivityLogModel(); - $logModel->log('login', "User logged in as {$user['role']}", 'user', (int) $user['id']); + $result = $logModel->log('login', "User logged in as {$user['role']}", 'user', (int) $user['id']); + if (!$result) { + log_message('error', 'Failed to log login activity for user ' . $user['id']); + } if ($user['role'] === 'admin') { return redirect()->to(site_url('admin/dashboard')); diff --git a/app/Database/Migrations/2026-04-15-120000_AlterActivityLogsTable.php b/app/Database/Migrations/2026-04-15-120000_AlterActivityLogsTable.php new file mode 100644 index 0000000..fc7a9d6 --- /dev/null +++ b/app/Database/Migrations/2026-04-15-120000_AlterActivityLogsTable.php @@ -0,0 +1,132 @@ +db->disableForeignKeyChecks(); + $this->forge->dropTable('activity_logs', true); + $this->db->enableForeignKeyChecks(); + + // Create the new table with the specified structure + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'auto_increment' => true, + ], + 'ip' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + 'action' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => false, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'activity_user_id' => [ + 'type' => 'BIGINT', + 'null' => true, + ], + 'activity_user_type' => [ + 'type' => 'ENUM', + 'constraint' => ['admin', 'doctor', 'patient'], + 'null' => true, + ], + 'target_user_id' => [ + 'type' => 'BIGINT', + 'null' => true, + ], + 'target_user_type' => [ + 'type' => 'ENUM', + 'constraint' => ['admin', 'doctor', 'patient'], + 'null' => true, + ], + 'activity_page' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'activity_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('activity_user_id'); + $this->forge->addKey('target_user_id'); + $this->forge->addKey('action'); + $this->forge->addKey('activity_at'); + $this->forge->addKey('ip'); + + $this->forge->createTable('activity_logs'); + } + + public function down() + { + // Revert to original table structure if rollback + $this->db->disableForeignKeyChecks(); + $this->forge->dropTable('activity_logs', true); + $this->db->enableForeignKeyChecks(); + + // Recreate original structure + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'auto_increment' => true, + ], + 'actor_user_id' => [ + 'type' => 'BIGINT', + 'null' => true, + ], + 'actor_role' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + 'action' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'target_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'target_id' => [ + 'type' => 'BIGINT', + 'null' => true, + ], + 'ip_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + 'user_agent' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->createTable('activity_logs'); + } +} diff --git a/app/Models/ActivityLogModel.php b/app/Models/ActivityLogModel.php index 719bac8..382aed6 100644 --- a/app/Models/ActivityLogModel.php +++ b/app/Models/ActivityLogModel.php @@ -10,70 +10,210 @@ class ActivityLogModel extends Model protected $primaryKey = 'id'; protected $useAutoIncrement = true; protected $returnType = 'array'; + protected $useSoftDeletes = false; protected $protectFields = true; - protected $allowedFields = [ - 'actor_user_id', - 'actor_role', - 'action', - 'description', - 'target_type', - 'target_id', - 'ip_address', - 'user_agent', - 'created_at', - ]; + protected $allowedFields = ['ip', 'action', 'description', 'activity_user_id', 'activity_user_type', 'target_user_id', 'target_user_type', 'activity_page', 'activity_at']; - public function log(string $action, string $description, ?string $targetType = null, ?int $targetId = null): void + protected bool $allowEmptyInserts = false; + protected bool $updateOnlyChanged = true; + + protected array $casts = []; + protected array $castHandlers = []; + + protected $useTimestamps = false; + protected $dateFormat = 'datetime'; + protected $createdField = 'activity_at'; + protected $updatedField = 'updated_at'; + protected $deletedField = 'deleted_at'; + + protected $validationRules = []; + protected $validationMessages = []; + protected $skipValidation = false; + protected $cleanValidationRules = true; + + protected $allowCallbacks = true; + protected $beforeInsert = []; + protected $afterInsert = []; + protected $beforeUpdate = []; + protected $afterUpdate = []; + protected $beforeFind = []; + protected $afterFind = []; + protected $beforeDelete = []; + protected $afterDelete = []; + + public function log(string $action, ?string $description = null, ?string $targetType = null, ?int $targetId = null, ?int $actorId = null, ?string $actorRole = null, ?string $ipAddress = null, ?string $userAgent = null): bool { + $actorId = $actorId ?? session()->get('id'); + $actorRole = $actorRole ?? session()->get('role') ?? 'guest'; + $request = service('request'); + $ipAddress = $ipAddress ?? ($request->hasHeader('X-Forwarded-For') ? trim(explode(',', $request->getHeaderLine('X-Forwarded-For'))[0]) : $request->getIPAddress()); + $userAgent = $userAgent ?? ($request->hasHeader('User-Agent') ? $request->getHeaderLine('User-Agent') : null); + $validTargetTypes = ['admin', 'doctor', 'patient']; + $targetUserType = in_array($targetType, $validTargetTypes, true) ? $targetType : null; - $this->insert([ - 'actor_user_id' => session()->get('id') ? (int) session()->get('id') : null, - 'actor_role' => session()->get('role') ?: null, - 'action' => $action, - 'description' => $description, - 'target_type' => $targetType, - 'target_id' => $targetId, - 'ip_address' => method_exists($request, 'getIPAddress') ? $request->getIPAddress() : null, - 'user_agent' => method_exists($request, 'getUserAgent') ? $request->getUserAgent()->getAgentString() : null, - 'created_at' => date('Y-m-d H:i:s'), - ]); + $data = [ + 'ip' => $ipAddress, + 'action' => $action, + 'description' => $description, + 'activity_user_id' => $actorId, + 'activity_user_type' => $actorRole, + 'target_user_id' => $targetId, + 'target_user_type' => $targetUserType, + 'activity_page' => $request->getPath(), + 'activity_at' => date('Y-m-d H:i:s'), + ]; + + try { + $result = $this->insert($data); + if (!$result) { + log_message('error', 'Failed to insert activity log: ' . json_encode($data) . ' - Errors: ' . json_encode($this->errors())); + } + return (bool) $result; + } catch (\Exception $e) { + log_message('error', 'Exception in ActivityLogModel::log: ' . $e->getMessage()); + return false; + } } - public function getFiltered(array $filters = []): array + public function getFilteredLogs(array $filters = [], int $limit = 200): array { - $builder = $this->db->table('activity_logs al') - ->select("al.*, CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, '')) AS actor_name, u.email AS actor_email") - ->join('users u', 'u.id = al.actor_user_id', 'left'); + $builder = $this->select('activity_logs.*, COALESCE(NULLIF(CONCAT(users.first_name, " ", users.last_name), " "), users.email, "Guest") AS actor_name') + ->join('users', 'users.id = activity_logs.activity_user_id', 'left'); - if (! empty($filters['action'])) { - $builder->like('al.action', $filters['action']); + // Apply filters + if (!empty($filters['search'])) { + $search = $filters['search']; + $builder->groupStart() + ->like('users.first_name', $search) + ->orLike('users.last_name', $search) + ->orLike('users.email', $search) + ->orLike('activity_logs.description', $search) + ->groupEnd(); } - if (! empty($filters['role'])) { - $builder->where('al.actor_role', $filters['role']); + if (!empty($filters['action'])) { + $builder->where('activity_logs.action', $filters['action']); } - if (! empty($filters['date_from'])) { - $builder->where('DATE(al.created_at) >=', $filters['date_from']); + if (!empty($filters['user_type'])) { + $builder->where('activity_logs.activity_user_type', $filters['user_type']); } - if (! empty($filters['date_to'])) { - $builder->where('DATE(al.created_at) <=', $filters['date_to']); + if (!empty($filters['date_from'])) { + $builder->where('activity_logs.activity_at >=', $filters['date_from'] . ' 00:00:00'); } - return $builder - ->orderBy('al.created_at', 'DESC') - ->get() - ->getResultArray(); + if (!empty($filters['date_to'])) { + $builder->where('activity_logs.activity_at <=', $filters['date_to'] . ' 23:59:59'); + } + + if (!empty($filters['ip'])) { + $builder->where('activity_logs.ip', $filters['ip']); + } + + return $builder->orderBy('activity_logs.activity_at', 'DESC') + ->findAll($limit); } - public function getRecent(int $limit = 8): array + public function getAvailableActions(): array { - return $this->db->table('activity_logs') - ->orderBy('created_at', 'DESC') - ->limit($limit) - ->get() - ->getResultArray(); + $rows = $this->select('action') + ->distinct() + ->orderBy('action', 'ASC') + ->findAll(); + + return array_column($rows, 'action'); + } + + public function getActionSummary(): array + { + $rows = $this->select('action, COUNT(*) AS count') + ->groupBy('action') + ->orderBy('count', 'DESC') + ->findAll(); + + return array_column($rows, 'count', 'action'); + } + + public function getRoleSummary(): array + { + $rows = $this->select('activity_user_type AS actor_role, COUNT(*) AS count') + ->groupBy('activity_user_type') + ->orderBy('count', 'DESC') + ->findAll(); + + return array_column($rows, 'count', 'actor_role'); + } + + public function getSummary(string $period = '7_days'): array + { + $period = $period === '30_days' ? '30_days' : '7_days'; + $startDate = $period === '30_days' ? date('Y-m-d H:i:s', strtotime('-30 days')) : date('Y-m-d H:i:s', strtotime('-7 days')); + + return [ + 'total_actions' => (int) $this->where('activity_at >=', $startDate)->countAllResults(false), + 'by_action' => $this->getActionSummaryForPeriod($startDate), + 'by_role' => $this->getRoleSummaryForPeriod($startDate), + 'most_active_users' => $this->getMostActiveUsers($startDate), + 'unique_ips' => $this->getUniqueIPs($startDate), + ]; + } + + protected function getActionSummaryForPeriod(string $startDate): array + { + $rows = $this->select('action, COUNT(*) AS count') + ->where('activity_at >=', $startDate) + ->groupBy('action') + ->orderBy('count', 'DESC') + ->findAll(); + + return array_column($rows, 'count', 'action'); + } + + protected function getRoleSummaryForPeriod(string $startDate): array + { + $rows = $this->select('activity_user_type AS actor_role, COUNT(*) AS count') + ->where('activity_at >=', $startDate) + ->groupBy('activity_user_type') + ->orderBy('count', 'DESC') + ->findAll(); + + return array_column($rows, 'count', 'actor_role'); + } + + protected function getMostActiveUsers(string $startDate, int $limit = 10): array + { + return $this->select('COALESCE(NULLIF(TRIM(CONCAT(users.first_name, " ", users.last_name)), ""), users.email, "Guest") AS actor, COUNT(activity_logs.id) AS count') + ->join('users', 'users.id = activity_logs.activity_user_id', 'left') + ->where('activity_logs.activity_at >=', $startDate) + ->groupBy('activity_logs.activity_user_id') + ->orderBy('count', 'DESC') + ->findAll($limit); + } + + protected function getUniqueIPs(string $startDate, int $limit = 10): array + { + return $this->select('ip, COUNT(*) AS count') + ->where('activity_at >=', $startDate) + ->groupBy('ip') + ->orderBy('count', 'DESC') + ->findAll($limit); + } + + public function getCriticalActions(int $limit = 50): array + { + $criticalActions = ['admin_login', 'admin_password_change', 'system_error', 'security_breach']; + + return $this->select('activity_logs.*, COALESCE(NULLIF(TRIM(CONCAT(users.first_name, " ", users.last_name)), ""), users.email, "System") AS actor_name, users.email AS actor_email') + ->join('users', 'users.id = activity_logs.activity_user_id', 'left') + ->whereIn('action', $criticalActions) + ->orderBy('activity_at', 'DESC') + ->findAll($limit); + } + + public function clearAll(): bool + { + return $this->truncate(); } } diff --git a/app/Views/admin/activity_analytics.php b/app/Views/admin/activity_analytics.php index d797c6f..e592e38 100644 --- a/app/Views/admin/activity_analytics.php +++ b/app/Views/admin/activity_analytics.php @@ -72,17 +72,26 @@ function formatLabel($label) {
+<<<<<<< HEAD
+======= +
+>>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39

Total Actions

+<<<<<<< HEAD
+======= + +
+
+

+

Activity Roles

+
+
+
+>>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39

Active Users

@@ -218,11 +237,14 @@ function toggleSidebar() { icon.className = sidebar.classList.contains('collapsed') ? 'bi bi-layout-sidebar' : 'bi bi-list'; } +<<<<<<< HEAD function toggleNavDropdown(event, element) { event.preventDefault(); element.parentElement.classList.toggle('active'); } +======= +>>>>>>> b13edf05262009091e8d671b88fed19ecf9e1a39 function changeAnalyticsPeriod(period) { window.location.href = '?period=' + period; } diff --git a/app/Views/admin/activity_log.php b/app/Views/admin/activity_log.php index b00848c..ce465c3 100644 --- a/app/Views/admin/activity_log.php +++ b/app/Views/admin/activity_log.php @@ -8,8 +8,75 @@ + +