Files
TREK/server/src/routes/notifications.ts
T
jubnl bfe84b3016 feat(notifications): add ntfy as a first-class notification channel
Adds ntfy.sh (and self-hosted instances) as a new push notification
channel with full parity to the existing webhook channel.

- Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig,
  resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/
  Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per
  event), SSRF guard via existing checkSsrf + createPinnedDispatcher
- notificationPreferencesService: ntfy added to NotifChannel union,
  IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels,
  ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface
- notificationService: per-user ntfy dispatch after webhook block;
  admin-scoped ntfy via getAdminGlobalPref for version_available events
- Routes: POST /api/notifications/test-ntfy with saved-token fallback
- authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS,
  masked + encrypted on read/write
- settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS
- Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in
  NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method
- i18n: full English strings; English placeholders in 14 other locales
- Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests,
  MSW handler for test-ntfy endpoint
2026-04-15 13:59:25 +02:00

154 lines
6.2 KiB
TypeScript

import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications';
import {
getNotifications,
getUnreadCount,
markRead,
markUnread,
markAllRead,
deleteNotification,
deleteAll,
respondToBoolean,
} from '../services/inAppNotifications';
import { getPreferencesMatrix, setPreferences } from '../services/notificationPreferencesService';
const router = express.Router();
router.get('/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
});
router.put('/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
setPreferences(authReq.user.id, req.body);
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
});
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
const { email } = req.body;
res.json(await testSmtp(email || authReq.user.email));
});
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
let { url } = req.body;
if (!url || url === '••••••••') {
url = getUserWebhookUrl(authReq.user.id);
if (!url && authReq.user.role === 'admin') url = getAdminWebhookUrl();
if (!url) return res.status(400).json({ error: 'No webhook URL configured' });
}
if (typeof url !== 'string') return res.status(400).json({ error: 'url must be a string' });
try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
res.json(await testWebhook(url));
});
router.post('/test-ntfy', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { topic, server, token } = req.body as { topic?: string; server?: string; token?: string };
// Always load saved config for fallbacks (token may be masked or absent in request)
const userCfg = getUserNtfyConfig(authReq.user.id);
const adminCfg = getAdminNtfyConfig();
const resolvedTopic = topic || userCfg?.topic || undefined;
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
// Reuse saved token when request sends null, empty, or the masked placeholder
const resolvedToken = (token && token !== '••••••••')
? token
: (userCfg?.token ?? adminCfg.token ?? null);
if (!resolvedTopic) return res.status(400).json({ error: 'No ntfy topic configured' });
res.json(await testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken }));
});
// ── 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;