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
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user