feat: implement user notifications system

- Add UI components for notification navigation (btn + badge) and notification list with unread indicators
- Introduce notifications service with CRUD methods for managing user notifications
- Trigger notifications when users like/favorite songs or playlists via social actions
- Add /account/notifications route to view and mark all notifications as read
- Display unread count in header and account page navigation
This commit is contained in:
Brian Fertig 2026-04-12 11:43:24 -06:00
parent d50117f334
commit 021d0694df
10 changed files with 245 additions and 9 deletions

View File

@ -0,0 +1,16 @@
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
actor_id INTEGER,
actor_name TEXT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
entity_title TEXT NOT NULL,
entity_slug TEXT,
read_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX notifications_user_unread ON notifications (user_id, read_at);

View File

@ -1203,6 +1203,94 @@ a.detail-creator-card:hover {
margin: 0; margin: 0;
} }
/* ---------- Notifications ---------- */
.notif-nav-btn {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 1.25rem;
margin-bottom: 1.5rem;
background: rgba(255, 179, 71, 0.08);
border: 1px solid rgba(255, 179, 71, 0.4);
border-radius: var(--radius);
color: var(--neon-amber);
font-size: 1rem;
font-weight: 600;
text-decoration: none;
transition: background 0.2s, box-shadow 0.2s;
}
.notif-nav-btn:hover {
background: rgba(255, 179, 71, 0.15);
box-shadow: 0 0 12px rgba(255, 179, 71, 0.25);
text-decoration: none;
}
.notif-nav-icon {
font-size: 1.15rem;
}
.notif-nav-label {
letter-spacing: 0.02em;
}
.notif-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--neon-amber);
color: #05060d;
font-family: 'Orbitron', sans-serif;
font-size: 0.58rem;
font-weight: 700;
min-width: 1.25em;
height: 1.25em;
border-radius: 99px;
padding: 0 0.3em;
margin-left: 0.3em;
box-shadow: 0 0 6px rgba(255, 179, 71, 0.7);
vertical-align: middle;
}
.notif-list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 680px;
}
.notif-item {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem;
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.notif-item.unread {
border-left: 3px solid var(--neon-amber);
box-shadow: 0 0 8px rgba(255, 179, 71, 0.15);
}
.notif-icon {
font-size: 1.1rem;
line-height: 1;
margin-top: 0.1rem;
flex-shrink: 0;
}
.notif-text {
font-size: 0.85rem;
line-height: 1.5;
}
.notif-text a {
color: var(--neon-cyan);
}
.notif-text a:hover {
text-decoration: underline;
}
.notif-time {
font-size: 0.72rem;
color: var(--muted);
margin-top: 0.2rem;
}
/* ---------- Mobile ---------- */ /* ---------- Mobile ---------- */
@media (max-width: 720px) { @media (max-width: 720px) {
.player-bar { flex-wrap: wrap; } .player-bar { flex-wrap: wrap; }

View File

@ -8,6 +8,7 @@ const pinoHttp = require('pino-http');
const { db } = require('../db'); const { db } = require('../db');
const SqliteStore = SqliteStoreFactory(session); const SqliteStore = SqliteStoreFactory(session);
const notifications = require('./services/notifications');
const app = express(); const app = express();
@ -62,6 +63,7 @@ app.use((req, res, next) => {
delete req.session.flash; delete req.session.flash;
res.locals.csrfToken = generateToken(req); res.locals.csrfToken = generateToken(req);
res.locals.currentPath = req.path; res.locals.currentPath = req.path;
res.locals.unreadNotifCount = req.session.user ? notifications.countUnread(req.session.user.id) : 0;
next(); next();
}); });

View File

@ -6,6 +6,7 @@ const { z } = require('zod');
const multer = require('multer'); const multer = require('multer');
const sharp = require('sharp'); const sharp = require('sharp');
const users = require('../services/users'); const users = require('../services/users');
const notifications = require('../services/notifications');
const { requireUser } = require('../middleware/auth'); const { requireUser } = require('../middleware/auth');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media'); const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
@ -38,6 +39,12 @@ module.exports = function accountRoutes(csrfProtection) {
res.render('account/index', { title: 'Account', errors: {}, values: {} }); res.render('account/index', { title: 'Account', errors: {}, values: {} });
}); });
router.get('/notifications', (req, res) => {
const items = notifications.list(req.session.user.id);
notifications.markAllRead(req.session.user.id);
res.render('account/notifications', { title: 'Notifications', items });
});
router.post('/', csrfProtection, (req, res, next) => { router.post('/', csrfProtection, (req, res, next) => {
try { try {
const parsed = profileSchema.safeParse(req.body); const parsed = profileSchema.safeParse(req.body);

View File

@ -0,0 +1,40 @@
const { db } = require('../../db');
const insertStmt = db.prepare(`
INSERT INTO notifications (user_id, actor_id, actor_name, action, entity_type, entity_id, entity_title, entity_slug)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const countUnreadStmt = db.prepare(`
SELECT COUNT(*) AS c FROM notifications WHERE user_id = ? AND read_at IS NULL
`);
const listStmt = db.prepare(`
SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?
`);
const markAllReadStmt = db.prepare(`
UPDATE notifications SET read_at = datetime('now') WHERE user_id = ? AND read_at IS NULL
`);
function create({ userId, actorId, actorName, action, entityType, entityId, entityTitle, entitySlug }) {
// No self-notifications
if (userId === actorId) return;
// No notification if owner is unknown
if (!userId) return;
insertStmt.run(userId, actorId || null, actorName || null, action, entityType, entityId, entityTitle, entitySlug || null);
}
function countUnread(userId) {
return countUnreadStmt.get(userId).c;
}
function list(userId, limit = 50) {
return listStmt.all(userId, limit);
}
function markAllRead(userId) {
markAllReadStmt.run(userId);
}
module.exports = { create, countUnread, list, markAllRead };

View File

@ -1,4 +1,14 @@
const { db } = require('../../db'); const { db } = require('../../db');
const notifications = require('./notifications');
// ── Lookup statements for notification context ───────────────────────────────
const songOwnerStmt = db.prepare('SELECT uploaded_by, title, slug FROM songs WHERE id = ?');
const playlistOwnerStmt = db.prepare('SELECT created_by, title, slug FROM playlists WHERE id = ?');
const userNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?');
function getActorName(actorId) {
return actorId ? (userNameStmt.get(actorId)?.display_name || null) : null;
}
// ── Song statements ────────────────────────────────────────────────────────── // ── Song statements ──────────────────────────────────────────────────────────
const songLikeCountStmt = db.prepare('SELECT COUNT(*) AS c FROM song_likes WHERE song_id = ?'); const songLikeCountStmt = db.prepare('SELECT COUNT(*) AS c FROM song_likes WHERE song_id = ?');
@ -23,29 +33,49 @@ const deletePlFav = db.prepare('DELETE FROM playlist_favorites WHERE use
// ── Toggle functions ───────────────────────────────────────────────────────── // ── Toggle functions ─────────────────────────────────────────────────────────
function toggleSongLike(userId, songId) { function toggleSongLike(userId, songId) {
const exists = songUserLikedStmt.get(userId, songId); const exists = songUserLikedStmt.get(userId, songId);
if (exists) deleteSongLike.run(userId, songId); if (exists) {
else insertSongLike.run(userId, songId); deleteSongLike.run(userId, songId);
} else {
insertSongLike.run(userId, songId);
const song = songOwnerStmt.get(songId);
if (song) notifications.create({ userId: song.uploaded_by, actorId: userId, actorName: getActorName(userId), action: 'like', entityType: 'song', entityId: songId, entityTitle: song.title, entitySlug: song.slug });
}
return { liked: !exists, count: songLikeCountStmt.get(songId).c }; return { liked: !exists, count: songLikeCountStmt.get(songId).c };
} }
function toggleSongFavorite(userId, songId) { function toggleSongFavorite(userId, songId) {
const exists = songUserFavoritedStmt.get(userId, songId); const exists = songUserFavoritedStmt.get(userId, songId);
if (exists) deleteSongFav.run(userId, songId); if (exists) {
else insertSongFav.run(userId, songId); deleteSongFav.run(userId, songId);
} else {
insertSongFav.run(userId, songId);
const song = songOwnerStmt.get(songId);
if (song) notifications.create({ userId: song.uploaded_by, actorId: userId, actorName: getActorName(userId), action: 'favorite', entityType: 'song', entityId: songId, entityTitle: song.title, entitySlug: song.slug });
}
return { favorited: !exists, count: songFavCountStmt.get(songId).c }; return { favorited: !exists, count: songFavCountStmt.get(songId).c };
} }
function togglePlaylistLike(userId, playlistId) { function togglePlaylistLike(userId, playlistId) {
const exists = plUserLikedStmt.get(userId, playlistId); const exists = plUserLikedStmt.get(userId, playlistId);
if (exists) deletePlLike.run(userId, playlistId); if (exists) {
else insertPlLike.run(userId, playlistId); deletePlLike.run(userId, playlistId);
} else {
insertPlLike.run(userId, playlistId);
const pl = playlistOwnerStmt.get(playlistId);
if (pl) notifications.create({ userId: pl.created_by, actorId: userId, actorName: getActorName(userId), action: 'like', entityType: 'playlist', entityId: playlistId, entityTitle: pl.title, entitySlug: pl.slug });
}
return { liked: !exists, count: plLikeCountStmt.get(playlistId).c }; return { liked: !exists, count: plLikeCountStmt.get(playlistId).c };
} }
function togglePlaylistFavorite(userId, playlistId) { function togglePlaylistFavorite(userId, playlistId) {
const exists = plUserFavoritedStmt.get(userId, playlistId); const exists = plUserFavoritedStmt.get(userId, playlistId);
if (exists) deletePlFav.run(userId, playlistId); if (exists) {
else insertPlFav.run(userId, playlistId); deletePlFav.run(userId, playlistId);
} else {
insertPlFav.run(userId, playlistId);
const pl = playlistOwnerStmt.get(playlistId);
if (pl) notifications.create({ userId: pl.created_by, actorId: userId, actorName: getActorName(userId), action: 'favorite', entityType: 'playlist', entityId: playlistId, entityTitle: pl.title, entitySlug: pl.slug });
}
return { favorited: !exists, count: plFavCountStmt.get(playlistId).c }; return { favorited: !exists, count: plFavCountStmt.get(playlistId).c };
} }

View File

@ -38,6 +38,10 @@ const listVerifiedStmt = db.prepare(`
(SELECT COUNT(*) FROM playlists WHERE created_by = u.id) AS playlist_count (SELECT COUNT(*) FROM playlists WHERE created_by = u.id) AS playlist_count
FROM users u FROM users u
WHERE u.email_verified = 1 AND u.disabled = 0 WHERE u.email_verified = 1 AND u.disabled = 0
AND (
EXISTS (SELECT 1 FROM songs WHERE uploaded_by = u.id)
OR EXISTS (SELECT 1 FROM playlists WHERE created_by = u.id)
)
ORDER BY u.display_name ASC ORDER BY u.display_name ASC
`); `);

View File

@ -1,5 +1,10 @@
<h1>Account</h1> <h1>Account</h1>
<p class="muted"><%= user.email %> · <%= user.role %></p> <p class="muted"><%= user.email %> · <%= user.role %></p>
<a href="/account/notifications" class="notif-nav-btn">
<span class="notif-nav-icon">🔔</span>
<span class="notif-nav-label">Notifications</span>
<% if (unreadNotifCount > 0) { %><span class="notif-badge"><%= unreadNotifCount %></span><% } %>
</a>
<section class="form-card"> <section class="form-card">
<h2>Profile picture</h2> <h2>Profile picture</h2>

View File

@ -0,0 +1,44 @@
<h1>Notifications</h1>
<% if (!items || items.length === 0) { %>
<p class="muted">No notifications yet.</p>
<% } else { %>
<ul class="notif-list">
<% items.forEach(function(item) {
const isUnread = !item.read_at;
const actorLabel = item.actor_name || 'Someone';
const actionLabel = item.action === 'like' ? 'liked' : 'favorited';
const entityLabel = item.entity_type === 'song' ? 'song' : 'playlist';
const entityPath = item.entity_type === 'song' ? '/songs/' : '/playlists/';
const icon = item.action === 'like' ? '👍' : '⭐';
// Relative time
const now = new Date();
const created = new Date(item.created_at + 'Z');
const diffMs = now - created;
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
let timeAgo;
if (diffMin < 1) timeAgo = 'just now';
else if (diffMin < 60) timeAgo = diffMin + ' minute' + (diffMin === 1 ? '' : 's') + ' ago';
else if (diffHr < 24) timeAgo = diffHr + ' hour' + (diffHr === 1 ? '' : 's') + ' ago';
else timeAgo = diffDay + ' day' + (diffDay === 1 ? '' : 's') + ' ago';
%>
<li class="notif-item<%= isUnread ? ' unread' : '' %>">
<span class="notif-icon"><%= icon %></span>
<div>
<div class="notif-text">
<strong><%= actorLabel %></strong> <%= actionLabel %> your <%= entityLabel %>
<% if (item.entity_slug) { %>
"<a href="<%= entityPath %><%= item.entity_slug %>"><%= item.entity_title %></a>"
<% } else { %>
"<%= item.entity_title %>"
<% } %>
</div>
<div class="notif-time"><%= timeAgo %></div>
</div>
</li>
<% }); %>
</ul>
<% } %>

View File

@ -10,7 +10,7 @@
<% if (user.role === 'admin') { %> <% if (user.role === 'admin') { %>
<a href="/mymusic/users" class="<%= currentPath && currentPath.startsWith('/mymusic/users') ? 'active' : '' %>">Users</a> <a href="/mymusic/users" class="<%= currentPath && currentPath.startsWith('/mymusic/users') ? 'active' : '' %>">Users</a>
<% } %> <% } %>
<a href="/account">Account</a> <a href="/account" class="<%= currentPath && currentPath.startsWith('/account') ? 'active' : '' %>">Account<% if (unreadNotifCount > 0) { %><span class="notif-badge"><%= unreadNotifCount %></span><% } %></a>
<form method="post" action="/logout" class="inline-form"> <form method="post" action="/logout" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>"> <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="linklike">Log out</button> <button type="submit" class="linklike">Log out</button>