mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 23:01:48 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user