feat: add in-app notification system with real-time delivery

Introduces a full in-app notification system with three types (simple,
boolean with server-side callbacks, navigate), three scopes (user, trip,
admin), fan-out persistence per recipient, and real-time push via
WebSocket. Includes a notification bell in the navbar, dropdown, dedicated
/notifications page, and a dev-only admin tab for testing all notification
variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jubnl
2026-04-02 18:57:52 +02:00
parent 979322025d
commit c0e9a771d6
32 changed files with 1837 additions and 8 deletions
+40
View File
@@ -311,4 +311,44 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
res.json({ success: true });
});
// ── Dev-only: test notification endpoints ──────────────────────────────────────
if (process.env.NODE_ENV === 'development') {
const { createNotification } = require('../services/inAppNotifications');
router.post('/dev/test-notification', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { type, scope, target, title_key, text_key, title_params, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback,
navigate_text_key, navigate_target } = req.body;
const input: Record<string, unknown> = {
type: type || 'simple',
scope: scope || 'user',
target: target ?? authReq.user.id,
sender_id: authReq.user.id,
title_key: title_key || 'notifications.test.title',
title_params: title_params || {},
text_key: text_key || 'notifications.test.text',
text_params: text_params || {},
};
if (type === 'boolean') {
input.positive_text_key = positive_text_key || 'notifications.test.accept';
input.negative_text_key = negative_text_key || 'notifications.test.decline';
input.positive_callback = positive_callback || { action: 'test_approve', payload: {} };
input.negative_callback = negative_callback || { action: 'test_deny', payload: {} };
} else if (type === 'navigate') {
input.navigate_text_key = navigate_text_key || 'notifications.test.goThere';
input.navigate_target = navigate_target || '/dashboard';
}
try {
const ids = createNotification(input);
res.json({ success: true, notification_ids: ids });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
}
export default router;
+93
View File
@@ -2,6 +2,16 @@ import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp, testWebhook } from '../services/notifications';
import {
getNotifications,
getUnreadCount,
markRead,
markUnread,
markAllRead,
deleteNotification,
deleteAll,
respondToBoolean,
} from '../services/inAppNotifications';
import * as prefsService from '../services/notificationPreferencesService';
const router = express.Router();
@@ -33,4 +43,87 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) =
res.json(await testWebhook());
});
// ── In-app notifications ──────────────────────────────────────────────────────
// GET /in-app — list notifications (paginated)
router.get('/in-app', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const offset = parseInt(req.query.offset as string) || 0;
const unreadOnly = req.query.unread_only === 'true';
const result = getNotifications(authReq.user.id, { limit, offset, unreadOnly });
res.json(result);
});
// GET /in-app/unread-count — badge count
router.get('/in-app/unread-count', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const count = getUnreadCount(authReq.user.id);
res.json({ count });
});
// PUT /in-app/read-all — mark all read (must be before /:id routes)
router.put('/in-app/read-all', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const count = markAllRead(authReq.user.id);
res.json({ success: true, count });
});
// DELETE /in-app/all — delete all (must be before /:id routes)
router.delete('/in-app/all', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const count = deleteAll(authReq.user.id);
res.json({ success: true, count });
});
// PUT /in-app/:id/read — mark single read
router.put('/in-app/:id/read', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
const ok = markRead(id, authReq.user.id);
if (!ok) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
});
// PUT /in-app/:id/unread — mark single unread
router.put('/in-app/:id/unread', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
const ok = markUnread(id, authReq.user.id);
if (!ok) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
});
// DELETE /in-app/:id — delete single
router.delete('/in-app/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
const ok = deleteNotification(id, authReq.user.id);
if (!ok) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
});
// POST /in-app/:id/respond — respond to a boolean notification
router.post('/in-app/:id/respond', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
const { response } = req.body;
if (response !== 'positive' && response !== 'negative') {
return res.status(400).json({ error: 'response must be "positive" or "negative"' });
}
const result = await respondToBoolean(id, authReq.user.id, response);
if (!result.success) return res.status(400).json({ error: result.error });
res.json({ success: true, notification: result.notification });
});
export default router;