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

View File

@@ -491,6 +491,33 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id);
`);
},
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')),
scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')),
target INTEGER NOT NULL,
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title_key TEXT NOT NULL,
title_params TEXT DEFAULT '{}',
text_key TEXT NOT NULL,
text_params TEXT DEFAULT '{}',
positive_text_key TEXT,
negative_text_key TEXT,
positive_callback TEXT,
negative_callback TEXT,
response TEXT CHECK(response IN ('positive', 'negative')),
navigate_text_key TEXT,
navigate_target TEXT,
is_read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
`);
},
];
if (currentVersion < migrations.length) {

View File

@@ -394,6 +394,30 @@ function createTables(db: Database.Database): void {
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')),
scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')),
target INTEGER NOT NULL,
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title_key TEXT NOT NULL,
title_params TEXT DEFAULT '{}',
text_key TEXT NOT NULL,
text_params TEXT DEFAULT '{}',
positive_text_key TEXT,
negative_text_key TEXT,
positive_callback TEXT,
negative_callback TEXT,
response TEXT CHECK(response IN ('positive', 'negative')),
navigate_text_key TEXT,
navigate_target TEXT,
is_read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
`);
}

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;

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;

View File

@@ -218,6 +218,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
notification_channel: notifChannel,
trip_reminders_enabled: tripRemindersEnabled,
permissions: authenticatedUser ? getAllPermissions() : undefined,
dev_mode: process.env.NODE_ENV === 'development',
};
}

View File

@@ -0,0 +1,22 @@
type ActionHandler = (payload: Record<string, unknown>, respondingUserId: number) => Promise<void>;
const actionRegistry = new Map<string, ActionHandler>();
function registerAction(actionType: string, handler: ActionHandler): void {
actionRegistry.set(actionType, handler);
}
function getAction(actionType: string): ActionHandler | undefined {
return actionRegistry.get(actionType);
}
// Dev/test actions
registerAction('test_approve', async () => {
console.log('[notifications] Test approve action executed');
});
registerAction('test_deny', async () => {
console.log('[notifications] Test deny action executed');
});
export { registerAction, getAction };

View File

@@ -0,0 +1,332 @@
import { db } from '../db/database';
import { broadcastToUser } from '../websocket';
import { getAction } from './notificationActions';
type NotificationType = 'simple' | 'boolean' | 'navigate';
type NotificationScope = 'trip' | 'user' | 'admin';
type NotificationResponse = 'positive' | 'negative';
interface BaseNotificationInput {
type: NotificationType;
scope: NotificationScope;
target: number;
sender_id: number | null;
title_key: string;
title_params?: Record<string, string>;
text_key: string;
text_params?: Record<string, string>;
}
interface SimpleNotificationInput extends BaseNotificationInput {
type: 'simple';
}
interface BooleanNotificationInput extends BaseNotificationInput {
type: 'boolean';
positive_text_key: string;
negative_text_key: string;
positive_callback: { action: string; payload: Record<string, unknown> };
negative_callback: { action: string; payload: Record<string, unknown> };
}
interface NavigateNotificationInput extends BaseNotificationInput {
type: 'navigate';
navigate_text_key: string;
navigate_target: string;
}
type NotificationInput = SimpleNotificationInput | BooleanNotificationInput | NavigateNotificationInput;
interface NotificationRow {
id: number;
type: NotificationType;
scope: NotificationScope;
target: number;
sender_id: number | null;
sender_username?: string | null;
sender_avatar?: string | null;
recipient_id: number;
title_key: string;
title_params: string;
text_key: string;
text_params: string;
positive_text_key: string | null;
negative_text_key: string | null;
positive_callback: string | null;
negative_callback: string | null;
response: NotificationResponse | null;
navigate_text_key: string | null;
navigate_target: string | null;
is_read: number;
created_at: string;
}
function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
let userIds: number[] = [];
if (scope === 'trip') {
const owner = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(target) as { user_id: number } | undefined;
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(target) as { user_id: number }[];
const ids = new Set<number>();
if (owner) ids.add(owner.user_id);
for (const m of members) ids.add(m.user_id);
userIds = Array.from(ids);
} else if (scope === 'user') {
userIds = [target];
} else if (scope === 'admin') {
const admins = db.prepare('SELECT id FROM users WHERE role = ?').all('admin') as { id: number }[];
userIds = admins.map(a => a.id);
}
// Only exclude sender for group scopes (trip/admin) — for user scope, the target is explicit
if (excludeUserId != null && scope !== 'user') {
userIds = userIds.filter(id => id !== excludeUserId);
}
return userIds;
}
function createNotification(input: NotificationInput): number[] {
const recipients = resolveRecipients(input.scope, input.target, input.sender_id);
if (recipients.length === 0) return [];
const titleParams = JSON.stringify(input.title_params ?? {});
const textParams = JSON.stringify(input.text_params ?? {});
const insertedIds: number[] = [];
const insert = db.transaction(() => {
const stmt = db.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback,
navigate_text_key, navigate_target
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const recipientId of recipients) {
let positiveTextKey: string | null = null;
let negativeTextKey: string | null = null;
let positiveCallback: string | null = null;
let negativeCallback: string | null = null;
let navigateTextKey: string | null = null;
let navigateTarget: string | null = null;
if (input.type === 'boolean') {
positiveTextKey = input.positive_text_key;
negativeTextKey = input.negative_text_key;
positiveCallback = JSON.stringify(input.positive_callback);
negativeCallback = JSON.stringify(input.negative_callback);
} else if (input.type === 'navigate') {
navigateTextKey = input.navigate_text_key;
navigateTarget = input.navigate_target;
}
const result = stmt.run(
input.type, input.scope, input.target, input.sender_id, recipientId,
input.title_key, titleParams, input.text_key, textParams,
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
navigateTextKey, navigateTarget
);
insertedIds.push(result.lastInsertRowid as number);
}
});
insert();
// Fetch sender info once for WS payloads
const sender = input.sender_id
? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(input.sender_id) as { username: string; avatar: string | null } | undefined)
: null;
// Broadcast to each recipient
for (let i = 0; i < insertedIds.length; i++) {
const notificationId = insertedIds[i];
const recipientId = recipients[i];
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow;
if (!row) continue;
broadcastToUser(recipientId, {
type: 'notification:new',
notification: {
...row,
sender_username: sender?.username ?? null,
sender_avatar: sender?.avatar ?? null,
},
});
}
return insertedIds;
}
function getNotifications(
userId: number,
options: { limit?: number; offset?: number; unreadOnly?: boolean } = {}
): { notifications: NotificationRow[]; total: number; unread_count: number } {
const limit = Math.min(options.limit ?? 20, 50);
const offset = options.offset ?? 0;
const unreadOnly = options.unreadOnly ?? false;
const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?';
const wherePlain = unreadOnly ? 'WHERE recipient_id = ? AND is_read = 0' : 'WHERE recipient_id = ?';
const rows = db.prepare(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n
LEFT JOIN users u ON n.sender_id = u.id
${whereAliased}
ORDER BY n.created_at DESC
LIMIT ? OFFSET ?
`).all(userId, limit, offset) as NotificationRow[];
const { total } = db.prepare(`SELECT COUNT(*) as total FROM notifications ${wherePlain}`).get(userId) as { total: number };
const { unread_count } = db.prepare('SELECT COUNT(*) as unread_count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { unread_count: number };
return { notifications: rows, total, unread_count };
}
function getUnreadCount(userId: number): number {
const row = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { count: number };
return row.count;
}
function markRead(notificationId: number, userId: number): boolean {
const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
return result.changes > 0;
}
function markUnread(notificationId: number, userId: number): boolean {
const result = db.prepare('UPDATE notifications SET is_read = 0 WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
return result.changes > 0;
}
function markAllRead(userId: number): number {
const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? AND is_read = 0').run(userId);
return result.changes;
}
function deleteNotification(notificationId: number, userId: number): boolean {
const result = db.prepare('DELETE FROM notifications WHERE id = ? AND recipient_id = ?').run(notificationId, userId);
return result.changes > 0;
}
function deleteAll(userId: number): number {
const result = db.prepare('DELETE FROM notifications WHERE recipient_id = ?').run(userId);
return result.changes;
}
async function respondToBoolean(
notificationId: number,
userId: number,
response: NotificationResponse
): Promise<{ success: boolean; error?: string; notification?: NotificationRow }> {
const notification = db.prepare('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?').get(notificationId, userId) as NotificationRow | undefined;
if (!notification) return { success: false, error: 'Notification not found' };
if (notification.type !== 'boolean') return { success: false, error: 'Not a boolean notification' };
if (notification.response !== null) return { success: false, error: 'Already responded' };
const callbackJson = response === 'positive' ? notification.positive_callback : notification.negative_callback;
if (!callbackJson) return { success: false, error: 'No callback defined' };
let callback: { action: string; payload: Record<string, unknown> };
try {
callback = JSON.parse(callbackJson);
} catch {
return { success: false, error: 'Invalid callback format' };
}
const handler = getAction(callback.action);
if (!handler) return { success: false, error: `Unknown action: ${callback.action}` };
try {
await handler(callback.payload, userId);
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Action failed' };
}
// Atomic update — only updates if response is still NULL (prevents double-response)
const result = db.prepare(
'UPDATE notifications SET response = ?, is_read = 1 WHERE id = ? AND recipient_id = ? AND response IS NULL'
).run(response, notificationId, userId);
if (result.changes === 0) return { success: false, error: 'Already responded' };
const updated = db.prepare(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n
LEFT JOIN users u ON n.sender_id = u.id
WHERE n.id = ?
`).get(notificationId) as NotificationRow;
broadcastToUser(userId, { type: 'notification:updated', notification: updated });
return { success: true, notification: updated };
}
interface NotificationPreferences {
id: number;
user_id: number;
notify_trip_invite: number;
notify_booking_change: number;
notify_trip_reminder: number;
notify_webhook: number;
}
interface PreferencesUpdate {
notify_trip_invite?: boolean;
notify_booking_change?: boolean;
notify_trip_reminder?: boolean;
notify_webhook?: boolean;
}
function getPreferences(userId: number): NotificationPreferences {
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences | undefined;
if (!prefs) {
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences;
}
return prefs;
}
function updatePreferences(userId: number, updates: PreferencesUpdate): NotificationPreferences {
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(userId);
if (!existing) {
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
}
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = updates;
db.prepare(`UPDATE notification_preferences SET
notify_trip_invite = COALESCE(?, notify_trip_invite),
notify_booking_change = COALESCE(?, notify_booking_change),
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
notify_webhook = COALESCE(?, notify_webhook)
WHERE user_id = ?`).run(
notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
userId
);
return db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as NotificationPreferences;
}
export {
createNotification,
getNotifications,
getUnreadCount,
markRead,
markUnread,
markAllRead,
deleteNotification,
deleteAll,
respondToBoolean,
getPreferences,
updatePreferences,
};
export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };