Add Clear button to notification bell dropdown

- Add notifications_cleared_at column to users table
- Clear button marks notifications as seen without deleting history
- Badge and dropdown only show notifications after last clear
- History page still shows all notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-24 03:20:03 -05:00
parent 262a91b558
commit 28d6523959
5 changed files with 69 additions and 11 deletions

View file

@ -122,6 +122,9 @@ async function runMigrations() {
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ai_verification_enabled') THEN
ALTER TABLE users ADD COLUMN ai_verification_enabled BOOLEAN DEFAULT false;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'notifications_cleared_at') THEN
ALTER TABLE users ADD COLUMN notifications_cleared_at TIMESTAMP;
END IF;
END $$;
`);

View file

@ -856,28 +856,41 @@ export const notificationHistoryQueries = {
return result.rows;
},
// Get recent notifications (for bell dropdown)
// Get recent notifications (for bell dropdown) - respects cleared_at
getRecent: async (userId: number, limit: number = 10): Promise<NotificationHistory[]> => {
const result = await pool.query(
`SELECT * FROM notification_history
WHERE user_id = $1
ORDER BY triggered_at DESC
`SELECT nh.* FROM notification_history nh
JOIN users u ON u.id = nh.user_id
WHERE nh.user_id = $1
AND (u.notifications_cleared_at IS NULL OR nh.triggered_at > u.notifications_cleared_at)
ORDER BY nh.triggered_at DESC
LIMIT $2`,
[userId, limit]
);
return result.rows;
},
// Count notifications in last 24 hours (for badge)
// Count notifications since last clear (for badge)
countRecent: async (userId: number, hours: number = 24): Promise<number> => {
const result = await pool.query(
`SELECT COUNT(*) FROM notification_history
WHERE user_id = $1 AND triggered_at > NOW() - INTERVAL '1 hour' * $2`,
`SELECT COUNT(*) FROM notification_history nh
JOIN users u ON u.id = nh.user_id
WHERE nh.user_id = $1
AND nh.triggered_at > NOW() - INTERVAL '1 hour' * $2
AND (u.notifications_cleared_at IS NULL OR nh.triggered_at > u.notifications_cleared_at)`,
[userId, hours]
);
return parseInt(result.rows[0].count, 10);
},
// Clear notifications (sets timestamp, doesn't delete)
clear: async (userId: number): Promise<void> => {
await pool.query(
`UPDATE users SET notifications_cleared_at = NOW() WHERE id = $1`,
[userId]
);
},
// Get total count for pagination
getTotalCount: async (userId: number): Promise<number> => {
const result = await pool.query(

View file

@ -69,4 +69,16 @@ router.get('/count', async (req: AuthRequest, res: Response) => {
}
});
// Clear notifications (marks as seen, doesn't delete history)
router.post('/clear', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
await notificationHistoryQueries.clear(userId);
res.json({ message: 'Notifications cleared' });
} catch (error) {
console.error('Error clearing notifications:', error);
res.status(500).json({ error: 'Failed to clear notifications' });
}
});
export default router;

View file

@ -313,6 +313,9 @@ export const notificationsApi = {
api.get<{ count: number }>('/notifications/count', {
params: hours ? { hours } : undefined,
}),
clear: () =>
api.post<{ message: string }>('/notifications/clear'),
};
// Admin API

View file

@ -99,6 +99,16 @@ export default function NotificationBell() {
}
};
const handleClear = async () => {
try {
await notificationsApi.clear();
setNotifications([]);
setRecentCount(0);
} catch (error) {
console.error('Failed to clear notifications:', error);
}
};
return (
<div className="notification-bell" ref={dropdownRef}>
<style>{`
@ -171,6 +181,22 @@ export default function NotificationBell() {
height: 18px;
}
.notification-clear-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.notification-clear-btn:hover {
background: var(--background);
color: var(--text);
}
.notification-list {
max-height: 320px;
overflow-y: auto;
@ -300,10 +326,11 @@ export default function NotificationBell() {
<div className="notification-dropdown">
<div className="notification-dropdown-header">
<span>Notifications</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{notifications.length > 0 && (
<button className="notification-clear-btn" onClick={handleClear}>
Clear
</button>
)}
</div>
{loading ? (