feat(notifications): implement notification system with CRUD operations and UI integration
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
This commit is contained in:
@@ -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) {
|
||||
|
||||
23
server/config/notifications.js
Normal file
23
server/config/notifications.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
48
server/routes/notifications.js
Normal file
48
server/routes/notifications.js
Normal 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;
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user