From 5cc53038269ad46edc1cad300985fb277c679ef9 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 13 Jun 2026 11:48:05 -0400 Subject: [PATCH] feat: track notification read receipts per user - New notification_reads table (migration 008) with unique (notification_id, user_id) - Notification model: markAsRead inserts into notification_reads; getForUser JOINs to show per-user read status; getUnreadCount checks NOT EXISTS in reads table - getAll() includes read_count subquery for admin view - Admin view shows 'X / Y' read count for broadcast notifications, 'Read'/'Unread' badge for user-targeted ones - Migrates existing read notifications into notification_reads - AGENTS.md updated with new conventions --- AGENTS.md | 3 +- .../migrations/008_notification_reads.sql | 18 +++++ public/assets/css/style.css | 1 + src/Controllers/AdminController.php | 3 + src/Models/Notification.php | 79 ++++++++++++++++--- views/admin/notifications.php | 16 +++- 6 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 database/migrations/008_notification_reads.sql diff --git a/AGENTS.md b/AGENTS.md index 5a0f6b9..5afa882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ Repo-specific guidance for OpenCode sessions. Verified against code. - Admin group routes (`/admin/*`) already include `AuthMiddleware` + `RoleMiddleware`; individual routes add `CSRFMiddleware`. - Rate limiting on `POST /login` only (via `RateLimitMiddleware`) - Every credential access must be logged at `warning` level via `AuditService` +- Notification read receipts tracked in `notification_reads` table: `(notification_id, user_id)` unique pair, with `read_at` timestamp ## Permission validation on server create/edit - When creating or editing a server, required SSH permissions can be specified and are validated via SSH before saving. @@ -49,7 +50,7 @@ Repo-specific guidance for OpenCode sessions. Verified against code. ## Database - Migrations run manually: `mysql servermanager < database/migrations/NNN_name.sql` -- 7 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications`, `006_agent_tokens`, `007_server_permission_checks` +- 8 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications`, `006_agent_tokens`, `007_server_permission_checks`, `008_notification_reads` - Users table has `role` ENUM: `super_admin`, `admin`, `operator` - `api_token` column on users for Bearer token auth - `server_permissions` table stores required SSH permissions per server (FK to `servers.id` CASCADE) diff --git a/database/migrations/008_notification_reads.sql b/database/migrations/008_notification_reads.sql new file mode 100644 index 0000000..6f490dd --- /dev/null +++ b/database/migrations/008_notification_reads.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS `notification_reads` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `notification_id` INT UNSIGNED NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + `read_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_notification_user` (`notification_id`, `user_id`), + KEY `idx_notification_id` (`notification_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Migrate existing read receipts for user-targeted notifications +INSERT IGNORE INTO `notification_reads` (`notification_id`, `user_id`, `read_at`) +SELECT n.id, u.id, COALESCE(n.read_at, n.created_at) +FROM notifications n +JOIN users u ON u.id = n.user_id AND u.status = 'active' +WHERE n.is_read = 1 + AND n.user_id IS NOT NULL; diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 45b68ce..a3615e6 100755 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1008,6 +1008,7 @@ textarea.form-input { .badge-warning { background: var(--warning-bg); color: var(--warning); } .badge-info { background: var(--info-bg); color: var(--info); } .badge-purple { background: var(--purple-bg); color: var(--purple); } +.badge-secondary { background: rgba(156, 163, 175, 0.15); color: #9ca3af; } /* ========================================================================== Mini Progress diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index c622bdd..0608d2e 100755 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -255,10 +255,13 @@ class AdminController $userModel = new User(); $users = $userModel->getAll(1, 200); + $totalActiveUsers = $notificationModel->getTotalActiveUsers(); + $this->view->display('admin.notifications', [ 'title' => 'Notifications - ServerManager', 'notifications' => $allNotifications, 'users' => $users, + 'totalActiveUsers' => $totalActiveUsers, ]); } diff --git a/src/Models/Notification.php b/src/Models/Notification.php index a45a500..6b0b7ff 100644 --- a/src/Models/Notification.php +++ b/src/Models/Notification.php @@ -29,15 +29,29 @@ class Notification public function getForUser(int $userId, int $page = 1, int $perPage = 20): array { $offset = ($page - 1) * $perPage; + $total = $this->db->fetch( - "SELECT COUNT(*) as total FROM notifications WHERE user_id IS NULL OR user_id = ?", + "SELECT COUNT(*) as total FROM notifications n + WHERE (n.user_id IS NULL OR n.user_id = ?)", [$userId] ); + $items = $this->db->fetchAll( - "SELECT * FROM notifications WHERE user_id IS NULL OR user_id = ? - ORDER BY created_at DESC LIMIT ? OFFSET ?", - [$userId, $perPage, $offset] + "SELECT n.*, + CASE + WHEN nr.id IS NOT NULL THEN 1 + ELSE 0 + END as is_read, + nr.read_at as read_at, + (SELECT COUNT(*) FROM notification_reads WHERE notification_id = n.id) as total_reads, + (SELECT COUNT(*) FROM users WHERE status = 'active') as total_users + FROM notifications n + LEFT JOIN notification_reads nr ON nr.notification_id = n.id AND nr.user_id = ? + WHERE n.user_id IS NULL OR n.user_id = ? + ORDER BY n.created_at DESC LIMIT ? OFFSET ?", + [$userId, $userId, $perPage, $offset] ); + return [ 'data' => $items, 'total' => (int) ($total['total'] ?? 0), @@ -50,15 +64,29 @@ class Notification public function getUnreadCount(int $userId): int { $result = $this->db->fetch( - "SELECT COUNT(*) as total FROM notifications - WHERE (user_id IS NULL OR user_id = ?) AND is_read = 0", - [$userId] + "SELECT COUNT(*) as total FROM notifications n + WHERE (n.user_id IS NULL OR n.user_id = ?) + AND NOT EXISTS ( + SELECT 1 FROM notification_reads nr + WHERE nr.notification_id = n.id AND nr.user_id = ? + )", + [$userId, $userId] ); return (int) ($result['total'] ?? 0); } public function markAsRead(int $id, int $userId): void { + try { + $this->db->insert('notification_reads', [ + 'notification_id' => $id, + 'user_id' => $userId, + 'read_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + // Already read, ignore + } + $this->db->update( 'notifications', ['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], @@ -69,9 +97,32 @@ class Notification public function markAllAsRead(int $userId): void { + $unread = $this->db->fetchAll( + "SELECT n.id FROM notifications n + WHERE (n.user_id IS NULL OR n.user_id = ?) + AND NOT EXISTS ( + SELECT 1 FROM notification_reads nr + WHERE nr.notification_id = n.id AND nr.user_id = ? + )", + [$userId, $userId] + ); + + $now = date('Y-m-d H:i:s'); + foreach ($unread as $note) { + try { + $this->db->insert('notification_reads', [ + 'notification_id' => (int) $note['id'], + 'user_id' => $userId, + 'read_at' => $now, + ]); + } catch (\Throwable $e) { + // Duplicate entry — already read, skip + } + } + $this->db->update( 'notifications', - ['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], + ['is_read' => 1, 'read_at' => $now], '(user_id IS NULL OR user_id = ?) AND is_read = 0', [$userId] ); @@ -80,14 +131,18 @@ class Notification public function getAll(int $page = 1, int $perPage = 20): array { $offset = ($page - 1) * $perPage; + $total = $this->db->fetch("SELECT COUNT(*) as total FROM notifications"); + $items = $this->db->fetchAll( - "SELECT n.*, u.username as target_username + "SELECT n.*, u.username as target_username, + (SELECT COUNT(*) FROM notification_reads nr WHERE nr.notification_id = n.id) as read_count FROM notifications n LEFT JOIN users u ON n.user_id = u.id ORDER BY n.created_at DESC LIMIT ? OFFSET ?", [$perPage, $offset] ); + return [ 'data' => $items, 'total' => (int) ($total['total'] ?? 0), @@ -96,4 +151,10 @@ class Notification 'total_pages' => (int) ceil((int) ($total['total'] ?? 0) / $perPage), ]; } + + public function getTotalActiveUsers(): int + { + $result = $this->db->fetch("SELECT COUNT(*) as total FROM users WHERE status = 'active'"); + return (int) ($result['total'] ?? 0); + } } diff --git a/views/admin/notifications.php b/views/admin/notifications.php index 6465f9b..4dd5581 100644 --- a/views/admin/notifications.php +++ b/views/admin/notifications.php @@ -1,6 +1,7 @@ @@ -27,7 +28,7 @@ $data = $notifications['data'] ?? []; Title Message Target - Status + Read by @@ -53,9 +54,16 @@ $data = $notifications['data'] ?? []; 80 ? '...' : '' ?> All Users' ?> - - - + + + 0 ? 'Read' : 'Unread' ?> + + + + + / + +