From c0e01c6dae5dfed3003f0b938cf113607f17138a Mon Sep 17 00:00:00 2001 From: Sayan Das Date: Fri, 17 Apr 2026 10:46:29 +0530 Subject: [PATCH] Update --- app/Commands/SendActivityDigest.php | 209 ++++++++++++ app/Config/Autoload.php | 2 +- app/Config/Routes.php | 4 + app/Controllers/ActivityLog.php | 88 ++++- ...26-04-15-120000_AlterActivityLogsTable.php | 132 +++++++ app/Helpers/activity_helper.php | 100 ++++++ app/Helpers/time_helper.php | 3 + app/Models/ActivityLogModel.php | 214 +++++++++--- app/Views/admin/activity_analytics.php | 322 ++++++++++++++++++ app/Views/admin/activity_log.php | 270 ++++++++++++--- temp_check.php | 6 + 11 files changed, 1255 insertions(+), 95 deletions(-) create mode 100644 app/Commands/SendActivityDigest.php create mode 100644 app/Database/Migrations/2026-04-15-120000_AlterActivityLogsTable.php create mode 100644 app/Helpers/activity_helper.php create mode 100644 app/Helpers/time_helper.php create mode 100644 app/Views/admin/activity_analytics.php create mode 100644 temp_check.php diff --git a/app/Commands/SendActivityDigest.php b/app/Commands/SendActivityDigest.php new file mode 100644 index 0000000..656ac34 --- /dev/null +++ b/app/Commands/SendActivityDigest.php @@ -0,0 +1,209 @@ + 'Digest period: daily, weekly, or monthly (default: daily)', + ]; + + public function run(array $params = []) + { + $period = $params[0] ?? 'daily'; + + if (!in_array($period, ['daily', 'weekly', 'monthly'])) { + CLI::error('Invalid period. Use: daily, weekly, or monthly'); + return; + } + + $activityModel = new ActivityLogModel(); + $userModel = new UserModel(); + + // Determine date range + $startDate = match($period) { + 'daily' => date('Y-m-d H:i:s', strtotime('-1 day')), + 'weekly' => date('Y-m-d H:i:s', strtotime('-7 days')), + 'monthly' => date('Y-m-d H:i:s', strtotime('-30 days')), + default => date('Y-m-d H:i:s', strtotime('-1 day')), + }; + + // Get activity summary for the period + $db = \Config\Database::connect(); + $logs = $db->table('activity_logs') + ->where('activity_at >=', $startDate) + ->orderBy('activity_at', 'DESC') + ->get() + ->getResultArray(); + + if (empty($logs)) { + CLI::write('No activity found for ' . $period . ' digest', 'yellow'); + return; + } + + // Get admin users + $admins = $userModel->where('role', 'admin')->findAll(); + + if (empty($admins)) { + CLI::error('No admin users found to send digest to'); + return; + } + + // Send email to each admin + $email = service('email'); + $emailConfig = config('Email'); + + $successCount = 0; + $failCount = 0; + + foreach ($admins as $admin) { + $html = $this->generateDigestHTML($logs, $period, $admin); + + $email->setFrom($emailConfig->fromEmail, $emailConfig->fromName) + ->setTo($admin['email']) + ->setSubject(ucfirst($period) . ' Activity Digest - ' . date('Y-m-d')) + ->setMessage($html); + + if ($email->send(false)) { + $successCount++; + CLI::write('Email sent to: ' . $admin['email'], 'green'); + } else { + $failCount++; + CLI::error('Failed to send email to: ' . $admin['email']); + } + + $email->clear(); + } + + CLI::write("\nDigest email summary:", 'cyan'); + CLI::write('Sent: ' . $successCount, 'green'); + CLI::write('Failed: ' . $failCount, 'red'); + } + + protected function generateDigestHTML($logs, $period, $admin) + { + $totalActions = count($logs); + + // Group by action + $byAction = []; + foreach ($logs as $log) { + $action = $log['action']; + $byAction[$action] = ($byAction[$action] ?? 0) + 1; + } + + // Get critical actions + $criticalActions = array_filter($logs, function($log) { + return stripos($log['action'], 'delete') !== false; + }); + + $actionTypeCount = count($byAction); + $criticalActionCount = count($criticalActions); + + $html = << + + + + + + +
+
+

Activity Digest Report

+

Dear {$admin['first_name']}, here is your {$period} activity summary

+
+ +
+
+
+
{$totalActions}
+
Total Actions
+
+
+
{$actionTypeCount}
+
Action Types
+
+
+
{$criticalActionCount}
+
Critical Actions
+
+
+ +

Top Actions

+ + + + + + + + +HTML; + + arsort($byAction); + foreach (array_slice($byAction, 0, 10) as $action => $count) { + $isCritical = stripos($action, 'delete') !== false ? 'class="critical"' : ''; + $html .= ""; + } + + $html .= << +
ActionCount
{$action}{$count}
+ +

Critical Actions (Deletions)

+HTML; + + if (!empty($criticalActions)) { + $html .= ''; + foreach (array_slice($criticalActions, 0, 20) as $log) { + $userId = $log['activity_user_id'] ?? 'System'; + $targetType = $log['target_user_type'] ?? '-'; + $html .= ""; + $html .= ""; + $html .= ""; + $html .= ""; + $html .= ""; + $html .= ""; + } + $html .= '
TimeUserActionTarget
" . date('Y-m-d H:i', strtotime($log['activity_at'])) . "{$userId}{$log['action']}{$targetType}
'; + } else { + $html .= '

No critical actions detected.

'; + } + + $html .= << +

This is an automated email. Please do not reply to this message.

+

Generated on {date('Y-m-d H:i:s')}

+
+
+ + + +HTML; + + return $html; + } +} diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 4e6e184..1f3a741 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -88,5 +88,5 @@ class Autoload extends AutoloadConfig * * @var list */ - public $helpers = ['form', 'url', 'encryption']; + public $helpers = ['form', 'url', 'encryption', 'activity']; } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index de6aa42..d2f5bdc 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -47,3 +47,7 @@ $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/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..482bcf2 100644 --- a/app/Controllers/ActivityLog.php +++ b/app/Controllers/ActivityLog.php @@ -12,17 +12,87 @@ 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); return view('admin/activity_log', [ - 'logs' => $logModel->getFiltered($filters), - 'filters' => $filters, + 'logs' => $logs, + 'totalLogs' => count($logs), + '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 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/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/Helpers/activity_helper.php b/app/Helpers/activity_helper.php new file mode 100644 index 0000000..18d7d5c --- /dev/null +++ b/app/Helpers/activity_helper.php @@ -0,0 +1,100 @@ +get('id'); + return $userId ? (int) $userId : null; +} + +/** + * Get the current activity user type/role from session + * + * @return string The user role (admin, doctor, patient) or 'guest' + */ +function getActivityUserType(): string +{ + return session()->get('role') ?: 'guest'; +} + +/** + * Get the current page/path being accessed + * + * @return string The current request path + */ +function getActivityPage(): string +{ + $request = service('request'); + return $request->getPath(); +} + +/** + * Get the client IP address + * + * @return string|null The client IP address or null if unavailable + */ +function getActivityIP(): ?string +{ + $request = service('request'); + if (method_exists($request, 'getIPAddress')) { + return $request->getIPAddress(); + } + return null; +} + +/** + * Get all activity metadata at once + * + * Useful for logging operations that need complete activity context + * + * @return array Array containing user_id, user_type, page, and ip + */ +function getActivityMetadata(): array +{ + return [ + 'user_id' => getActivityUserId(), + 'user_type' => getActivityUserType(), + 'page' => getActivityPage(), + 'ip' => getActivityIP(), + ]; +} + +/** + * Check if current user is authenticated + * + * @return bool True if user is logged in, false otherwise + */ +function isActivityUserAuthenticated(): bool +{ + return getActivityUserId() !== null; +} + +/** + * Get formatted user identifier for logging + * + * Returns a string like "User #123 (admin)" or "Guest" + * + * @return string Formatted user identifier + */ +function getFormattedActivityUser(): string +{ + $userId = getActivityUserId(); + $userType = getActivityUserType(); + + if ($userId === null) { + return 'Guest'; + } + + return "User #{$userId} ({$userType})"; +} diff --git a/app/Helpers/time_helper.php b/app/Helpers/time_helper.php new file mode 100644 index 0000000..ef01a03 --- /dev/null +++ b/app/Helpers/time_helper.php @@ -0,0 +1,3 @@ +get('id'); + $actorRole = $actorRole ?? session()->get('role') ?? 'guest'; - $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'), + $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; + + return (bool) $this->insert([ + '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'), ]); } - 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); } } diff --git a/app/Views/admin/activity_analytics.php b/app/Views/admin/activity_analytics.php new file mode 100644 index 0000000..1979d6d --- /dev/null +++ b/app/Views/admin/activity_analytics.php @@ -0,0 +1,322 @@ + 20 ? substr($label, 0, 20) . '...' : $label; +} +?> + + + + + + Activity Analytics Dashboard + + + + + + + + + + +
+
+
+ +

Activity Analytics

+
+
+ +
+
+
+

Analytics Dashboard

+
+ + Back to Logs +
+
+
+ +
+
+
+

+

Total Actions

+
+
+
+
+

+

Action Types

+
+
+
+
+

+

Active Roles

+
+
+
+
+

+

Active Users

+
+
+
+ + + + + + +
+
+
Actions Distribution
+
+ +
+
+
+
Activity by Role
+
+ +
+
+
+ +
+
+
Most Active Users
+
+ +
+
+
+
Top IP Addresses
+
+ + + + + + + + + + + + + + + + + + + + + +
IP AddressCount
No IP data available
+
+
+
+ +
+
+
+ + + +
+
+

Critical Actions (Recent)

+ critical actions +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampUserActionTargetDetails
No critical actions found
+
+ +
#
+
+
+
+ + + + + + diff --git a/app/Views/admin/activity_log.php b/app/Views/admin/activity_log.php index 0a68111..196325f 100644 --- a/app/Views/admin/activity_log.php +++ b/app/Views/admin/activity_log.php @@ -8,8 +8,75 @@ + +