feat(notifications): implement notification system with CRUD operations and UI integration
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s

This commit is contained in:
2026-03-02 16:45:53 +01:00
parent 304349fce8
commit c13090bc80
16 changed files with 626 additions and 20 deletions

View File

@@ -617,6 +617,38 @@ export async function initDatabase() {
`);
}
// ── Notifications table ──────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
link TEXT,
read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
`);
}
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {

View File

@@ -0,0 +1,23 @@
import { getDb } from './database.js';
/**
* Create an in-app notification for a user.
* Non-fatal — exceptions are swallowed so that the main operation is never blocked.
*
* @param {number} userId - Recipient user ID
* @param {string} type - Notification type (room_share_added | room_share_removed | federation_invite_received)
* @param {string} title - Short title (e.g. room name or "from" address)
* @param {string|null} body - Optional longer message
* @param {string|null} link - Optional frontend path to navigate to when clicked
*/
export async function createNotification(userId, type, title, body = null, link = null) {
try {
const db = getDb();
await db.run(
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)',
[userId, type, title, body, link],
);
} catch {
// Notifications are non-critical — never break main functionality
}
}

View File

@@ -14,6 +14,7 @@ import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
import calendarRoutes from './routes/calendar.js';
import notificationRoutes from './routes/notifications.js';
import { startFederationSync } from './jobs/federationSync.js';
const __filename = fileURLToPath(import.meta.url);
@@ -55,6 +56,7 @@ async function start() {
app.use('/api/branding', brandingRoutes);
app.use('/api/federation', federationRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/notifications', notificationRoutes);
// Mount calendar federation receive also under /api/federation for remote instances
app.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler);

View File

@@ -83,6 +83,8 @@ router.get('/', async (req, res) => {
const logoFile = findLogoFile();
const registrationMode = await getSetting('registration_mode');
const imprintUrl = await getSetting('imprint_url');
const privacyUrl = await getSetting('privacy_url');
res.json({
appName: appName || 'Redlight',
@@ -90,6 +92,8 @@ router.get('/', async (req, res) => {
logoUrl: logoFile ? '/api/branding/logo' : null,
defaultTheme: defaultTheme || null,
registrationMode: registrationMode || 'open',
imprintUrl: imprintUrl || null,
privacyUrl: privacyUrl || null,
});
} catch (err) {
log.branding.error('Get branding error:', err);
@@ -210,4 +214,42 @@ router.put('/registration-mode', authenticateToken, requireAdmin, async (req, re
}
});
// PUT /api/branding/imprint-url - Set imprint URL (admin only)
router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => {
try {
const { imprintUrl } = req.body;
if (imprintUrl && imprintUrl.length > 500) {
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
}
if (imprintUrl && imprintUrl.trim()) {
await setSetting('imprint_url', imprintUrl.trim());
} else {
await deleteSetting('imprint_url');
}
res.json({ imprintUrl: imprintUrl?.trim() || null });
} catch (err) {
log.branding.error('Update imprint URL error:', err);
res.status(500).json({ error: 'Could not update imprint URL' });
}
});
// PUT /api/branding/privacy-url - Set privacy policy URL (admin only)
router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) => {
try {
const { privacyUrl } = req.body;
if (privacyUrl && privacyUrl.length > 500) {
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
}
if (privacyUrl && privacyUrl.trim()) {
await setSetting('privacy_url', privacyUrl.trim());
} else {
await deleteSetting('privacy_url');
}
res.json({ privacyUrl: privacyUrl?.trim() || null });
} catch (err) {
log.branding.error('Update privacy URL error:', err);
res.status(500).json({ error: 'Could not update privacy URL' });
}
});
export default router;

View File

@@ -5,6 +5,7 @@ import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
// M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({
@@ -233,6 +234,15 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
});
}
// In-app notification
await createNotification(
targetUser.id,
'federation_invite_received',
from_user,
room_name,
'/federation/inbox',
);
res.json({ success: true });
} catch (err) {
log.federation.error('Federation receive error:', err);

View File

@@ -0,0 +1,48 @@
import { Router } from 'express';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
const router = Router();
// GET /api/notifications — List recent notifications for the current user
router.get('/', authenticateToken, async (req, res) => {
try {
const db = getDb();
const notifications = await db.all(
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
[req.user.id],
);
const unreadCount = notifications.filter(n => !n.read).length;
res.json({ notifications, unreadCount });
} catch {
res.status(500).json({ error: 'Failed to load notifications' });
}
});
// POST /api/notifications/read-all — Mark all notifications as read
// NOTE: Must be declared before /:id/read to avoid routing collision
router.post('/read-all', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run('UPDATE notifications SET read = 1 WHERE user_id = ?', [req.user.id]);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to update notifications' });
}
});
// POST /api/notifications/:id/read — Mark a single notification as read
router.post('/:id/read', authenticateToken, async (req, res) => {
try {
const db = getDb();
await db.run(
'UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id],
);
res.json({ success: true });
} catch {
res.status(500).json({ error: 'Failed to update notification' });
}
});
export default router;

View File

@@ -7,6 +7,7 @@ import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
import {
createMeeting,
joinMeeting,
@@ -402,6 +403,15 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
// Notify the user who was given access
const sharerName = req.user.display_name || req.user.name;
await createNotification(
user_id,
'room_share_added',
room.name,
sharerName,
`/rooms/${room.uid}`,
);
res.json({ shares });
} catch (err) {
log.rooms.error(`Share room error: ${err.message}`);
@@ -417,13 +427,22 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
if (!room) {
return res.status(404).json({ error: 'Room not found or no permission' });
}
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
const removedUserId = parseInt(req.params.userId);
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]);
const shares = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
// Notify the user whose access was removed
await createNotification(
removedUserId,
'room_share_removed',
room.name,
null,
'/dashboard',
);
res.json({ shares });
} catch (err) {
log.rooms.error(`Remove share error: ${err.message}`);