Merge branch 'dev' into test
This commit is contained in:
@@ -1,33 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
<script type="module" crossorigin src="/assets/index-BBkAKwut.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CR224PtB.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -83,7 +83,8 @@ export function createApp(): express.Application {
|
||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson"
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
||||
"https://router.project-osrm.org/route/v1"
|
||||
],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
|
||||
@@ -736,6 +736,92 @@ function runMigrations(db: Database.Database): void {
|
||||
() => {
|
||||
try {db.exec('UPDATE addons SET enabled = 0 WHERE id = memories');} catch (err) {}
|
||||
}
|
||||
// Migration 69: Place region cache for sub-national Atlas regions
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS place_regions (
|
||||
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||
country_code TEXT NOT NULL,
|
||||
region_code TEXT NOT NULL,
|
||||
region_name TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code);
|
||||
`);
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS visited_regions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
region_code TEXT NOT NULL,
|
||||
region_name TEXT NOT NULL,
|
||||
country_code TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, region_code)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code);
|
||||
`);
|
||||
},
|
||||
// Migration 71: Normalized per-user per-channel notification preferences
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, event_type, channel)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||
`);
|
||||
|
||||
// Migrate data from old notification_preferences table (may not exist on fresh installs)
|
||||
const tableExists = (db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='notification_preferences'").get() as { name: string } | undefined) != null;
|
||||
const oldPrefs: Array<Record<string, number>> = tableExists
|
||||
? db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>
|
||||
: [];
|
||||
const eventCols: Record<string, string> = {
|
||||
trip_invite: 'notify_trip_invite',
|
||||
booking_change: 'notify_booking_change',
|
||||
trip_reminder: 'notify_trip_reminder',
|
||||
vacay_invite: 'notify_vacay_invite',
|
||||
photos_shared: 'notify_photos_shared',
|
||||
collab_message: 'notify_collab_message',
|
||||
packing_tagged: 'notify_packing_tagged',
|
||||
};
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
|
||||
for (const [userId, eventType, channel, enabled] of rows) {
|
||||
insert.run(userId, eventType, channel, enabled);
|
||||
}
|
||||
});
|
||||
|
||||
for (const row of oldPrefs) {
|
||||
const userId = row.user_id as number;
|
||||
const webhookEnabled = (row.notify_webhook as number) ?? 0;
|
||||
const rows: Array<[number, string, string, number]> = [];
|
||||
for (const [eventType, col] of Object.entries(eventCols)) {
|
||||
const emailEnabled = (row[col] as number) ?? 1;
|
||||
// Only insert if disabled (no row = enabled is our default)
|
||||
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
|
||||
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
|
||||
}
|
||||
if (rows.length > 0) insertMany(rows);
|
||||
}
|
||||
|
||||
// Copy existing single-channel setting to new plural key
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO app_settings (key, value)
|
||||
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
|
||||
`);
|
||||
},
|
||||
// Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71)
|
||||
() => {
|
||||
db.exec('DROP TABLE IF EXISTS notification_preferences;');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -449,6 +449,15 @@ function createTables(db: Database.Database): void {
|
||||
);
|
||||
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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, event_type, channel)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ const server = app.listen(PORT, () => {
|
||||
}
|
||||
scheduler.start();
|
||||
scheduler.startTripReminders();
|
||||
scheduler.startVersionCheck();
|
||||
scheduler.startDemoReset();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||
import * as svc from '../services/adminService';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -132,6 +133,19 @@ router.get('/version-check', async (_req: Request, res: Response) => {
|
||||
res.json(await svc.checkVersion());
|
||||
});
|
||||
|
||||
// ── Admin notification preferences ────────────────────────────────────────
|
||||
|
||||
router.get('/notification-preferences', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
|
||||
});
|
||||
|
||||
router.put('/notification-preferences', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
setAdminPreferences(authReq.user.id, req.body);
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
|
||||
});
|
||||
|
||||
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/invites', (_req: Request, res: Response) => {
|
||||
@@ -313,38 +327,22 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
||||
|
||||
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const { createNotification } = require('../services/inAppNotifications');
|
||||
const { send } = require('../services/notificationService');
|
||||
|
||||
router.post('/dev/test-notification', (req: Request, res: Response) => {
|
||||
router.post('/dev/test-notification', async (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';
|
||||
}
|
||||
const { event = 'trip_reminder', scope = 'user', targetId, params = {}, inApp } = req.body;
|
||||
|
||||
try {
|
||||
const ids = createNotification(input);
|
||||
res.json({ success: true, notification_ids: ids });
|
||||
await send({
|
||||
event,
|
||||
actorId: authReq.user.id,
|
||||
scope,
|
||||
targetId: targetId ?? authReq.user.id,
|
||||
params: { actor: authReq.user.email, ...params },
|
||||
inApp,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
getCountryPlaces,
|
||||
markCountryVisited,
|
||||
unmarkCountryVisited,
|
||||
markRegionVisited,
|
||||
unmarkRegionVisited,
|
||||
getVisitedRegions,
|
||||
getRegionGeo,
|
||||
listBucketList,
|
||||
createBucketItem,
|
||||
updateBucketItem,
|
||||
@@ -21,6 +25,21 @@ router.get('/stats', async (req: Request, res: Response) => {
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.get('/regions', async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
const data = await getVisitedRegions(userId);
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.get('/regions/geo', async (req: Request, res: Response) => {
|
||||
const countries = (req.query.countries as string || '').split(',').filter(Boolean);
|
||||
if (countries.length === 0) return res.json({ type: 'FeatureCollection', features: [] });
|
||||
const geo = await getRegionGeo(countries);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.json(geo);
|
||||
});
|
||||
|
||||
router.get('/country/:code', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const code = req.params.code.toUpperCase();
|
||||
@@ -39,6 +58,20 @@ router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/region/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const { name, country_code } = req.body;
|
||||
if (!name || !country_code) return res.status(400).json({ error: 'name and country_code are required' });
|
||||
markRegionVisited(userId, req.params.code.toUpperCase(), name, country_code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/region/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
unmarkRegionVisited(userId, req.params.code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Bucket List ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/bucket-list', (req: Request, res: Response) => {
|
||||
|
||||
@@ -79,9 +79,9 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
|
||||
res.status(201).json({ note: formatted });
|
||||
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email }).catch(() => {});
|
||||
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,10 +256,10 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
|
||||
broadcast(tripId, 'collab:message:created', { message: result.message }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new chat message
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview }).catch(() => {});
|
||||
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { testSmtp, testWebhook } from '../services/notifications';
|
||||
import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
@@ -12,22 +12,19 @@ import {
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
} from '../services/inAppNotifications';
|
||||
import * as prefsService from '../services/notificationPreferencesService';
|
||||
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({ preferences: prefsService.getPreferences(authReq.user.id) });
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
|
||||
});
|
||||
|
||||
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
|
||||
const preferences = prefsService.updatePreferences(authReq.user.id, {
|
||||
notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook
|
||||
});
|
||||
res.json({ preferences });
|
||||
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) => {
|
||||
@@ -39,8 +36,15 @@ router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||
res.json(await testWebhook());
|
||||
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));
|
||||
});
|
||||
|
||||
// ── In-app notifications ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -224,13 +224,10 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
||||
|
||||
// Notify newly assigned users
|
||||
if (Array.isArray(user_ids) && user_ids.length > 0) {
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
for (const uid of user_ids) {
|
||||
if (uid !== authReq.user.id) {
|
||||
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Use trip scope so the service resolves recipients — actor is excluded automatically
|
||||
send({ event: 'packing_tagged', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,9 +69,9 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new booking
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking' }).catch(() => {});
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,9 +137,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ reservation });
|
||||
broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking' }).catch(() => {});
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,9 +163,9 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ router.put('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { key, value } = req.body;
|
||||
if (!key) return res.status(400).json({ error: 'Key is required' });
|
||||
if (value === '••••••••') return res.json({ success: true, key, unchanged: true });
|
||||
settingsService.upsertSetting(authReq.user.id, key, value);
|
||||
res.json({ success: true, key, value });
|
||||
});
|
||||
|
||||
@@ -412,8 +412,8 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||
const result = addMember(req.params.id, identifier, tripOwnerId, authReq.user.id);
|
||||
|
||||
// Notify invited user
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
notify({ userId: result.targetUserId, event: 'trip_invite', params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email } }).catch(() => {});
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
send({ event: 'trip_invite', actorId: authReq.user.id, scope: 'user', targetId: result.targetUserId, params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email, tripId: String(req.params.id) } }).catch(() => {});
|
||||
});
|
||||
|
||||
res.status(201).json({ member: result.member });
|
||||
|
||||
@@ -163,22 +163,23 @@ function startTripReminders(): void {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
const channel = getSetting('notification_channel') || 'none';
|
||||
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
||||
const hasSmtp = !!(getSetting('smtp_host') || '').trim();
|
||||
const hasWebhook = !!(getSetting('notification_webhook_url') || '').trim();
|
||||
const channelReady = (channel === 'email' && hasSmtp) || (channel === 'webhook' && hasWebhook);
|
||||
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
||||
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
||||
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
|
||||
const hasWebhook = activeChannels.includes('webhook');
|
||||
const channelReady = hasEmail || hasWebhook;
|
||||
|
||||
if (!channelReady || !reminderEnabled) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
const reason = !channelReady ? `no ${channel === 'none' ? 'notification channel' : channel} configuration` : 'trip reminders disabled in settings';
|
||||
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
|
||||
li(`Trip reminders: disabled (${reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
|
||||
const { logInfo: liSetup } = require('./services/auditLog');
|
||||
liSetup(`Trip reminders: enabled via ${channel}${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||
liSetup(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +188,7 @@ function startTripReminders(): void {
|
||||
reminderTask = cron.schedule('0 9 * * *', async () => {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const { notifyTripMembers } = require('./services/notifications');
|
||||
const { send } = require('./services/notificationService');
|
||||
|
||||
const trips = db.prepare(`
|
||||
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
|
||||
@@ -197,7 +198,7 @@ function startTripReminders(): void {
|
||||
`).all() as { id: number; title: string; user_id: number; reminder_days: number }[];
|
||||
|
||||
for (const trip of trips) {
|
||||
await notifyTripMembers(trip.id, 0, 'trip_reminder', { trip: trip.title }).catch(() => {});
|
||||
await send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
|
||||
}
|
||||
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
@@ -211,10 +212,29 @@ function startTripReminders(): void {
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
// Version check: daily at 9 AM — notify admins if a new TREK release is available
|
||||
let versionCheckTask: ScheduledTask | null = null;
|
||||
|
||||
function startVersionCheck(): void {
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
versionCheckTask = cron.schedule('0 9 * * *', async () => {
|
||||
try {
|
||||
const { checkAndNotifyVersion } = require('./services/adminService');
|
||||
await checkAndNotifyVersion();
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Version check: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } f
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -313,6 +314,28 @@ export async function checkVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAndNotifyVersion(): Promise<void> {
|
||||
try {
|
||||
const result = await checkVersion();
|
||||
if (!result.update_available) return;
|
||||
|
||||
const lastNotified = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||
if (lastNotified === result.latest) return;
|
||||
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', result.latest);
|
||||
|
||||
await sendNotification({
|
||||
event: 'version_available',
|
||||
actorId: null,
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: result.latest as string },
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore — version check is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||
|
||||
export function listInvites() {
|
||||
|
||||
@@ -2,6 +2,38 @@ import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { Trip, Place } from '../types';
|
||||
|
||||
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
|
||||
|
||||
let admin1GeoCache: any = null;
|
||||
let admin1GeoLoading: Promise<any> | null = null;
|
||||
|
||||
async function loadAdmin1Geo(): Promise<any> {
|
||||
if (admin1GeoCache) return admin1GeoCache;
|
||||
if (admin1GeoLoading) return admin1GeoLoading;
|
||||
admin1GeoLoading = fetch(
|
||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
|
||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||
).then(r => r.json()).then(geo => {
|
||||
admin1GeoCache = geo;
|
||||
admin1GeoLoading = null;
|
||||
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
|
||||
return geo;
|
||||
}).catch(err => {
|
||||
admin1GeoLoading = null;
|
||||
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
|
||||
return null;
|
||||
});
|
||||
return admin1GeoLoading;
|
||||
}
|
||||
|
||||
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
||||
const geo = await loadAdmin1Geo();
|
||||
if (!geo) return { type: 'FeatureCollection', features: [] };
|
||||
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
||||
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
// ── Geocode cache ───────────────────────────────────────────────────────────
|
||||
|
||||
const geocodeCache = new Map<string, string | null>();
|
||||
@@ -339,6 +371,126 @@ export function markCountryVisited(userId: number, code: string): void {
|
||||
|
||||
export function unmarkCountryVisited(userId: number, code: string): void {
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
}
|
||||
|
||||
// ── Mark / unmark region ────────────────────────────────────────────────────
|
||||
|
||||
export function listManuallyVisitedRegions(userId: number): { region_code: string; region_name: string; country_code: string }[] {
|
||||
return db.prepare(
|
||||
'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as { region_code: string; region_name: string; country_code: string }[];
|
||||
}
|
||||
|
||||
export function markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): void {
|
||||
db.prepare('INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(userId, regionCode, regionName, countryCode);
|
||||
// Auto-mark parent country if not already visited
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode);
|
||||
}
|
||||
|
||||
export function unmarkRegionVisited(userId: number, regionCode: string): void {
|
||||
const region = db.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?').get(userId, regionCode) as { country_code: string } | undefined;
|
||||
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?').run(userId, regionCode);
|
||||
if (region) {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as count FROM visited_regions WHERE user_id = ? AND country_code = ?').get(userId, region.country_code) as { count: number };
|
||||
if (remaining.count === 0) {
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, region.country_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-national region resolution ────────────────────────────────────────
|
||||
|
||||
interface RegionInfo { country_code: string; region_code: string; region_name: string }
|
||||
|
||||
const regionCache = new Map<string, RegionInfo | null>();
|
||||
|
||||
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
||||
const key = roundKey(lat, lng);
|
||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
|
||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { address?: Record<string, string> };
|
||||
const countryCode = data.address?.country_code?.toUpperCase() || null;
|
||||
// Try finest ISO level first (lvl6 = departments/provinces), then lvl5, then lvl4 (states/regions)
|
||||
let regionCode = data.address?.['ISO3166-2-lvl6'] || data.address?.['ISO3166-2-lvl5'] || data.address?.['ISO3166-2-lvl4'] || null;
|
||||
// Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility)
|
||||
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
||||
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
||||
}
|
||||
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
|
||||
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
||||
const info: RegionInfo = {
|
||||
country_code: countryCode,
|
||||
region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`,
|
||||
region_name: regionName,
|
||||
};
|
||||
regionCache.set(key, info);
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVisitedRegions(userId: number): Promise<{ regions: Record<string, { code: string; name: string; placeCount: number }[]> }> {
|
||||
const trips = getUserTrips(userId);
|
||||
const tripIds = trips.map(t => t.id);
|
||||
const places = getPlacesForTrips(tripIds);
|
||||
|
||||
// Check DB cache first
|
||||
const placeIds = places.filter(p => p.lat && p.lng).map(p => p.id);
|
||||
const cached = placeIds.length > 0
|
||||
? db.prepare(`SELECT * FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`).all(...placeIds) as { place_id: number; country_code: string; region_code: string; region_name: string }[]
|
||||
: [];
|
||||
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
|
||||
|
||||
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
|
||||
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
|
||||
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
||||
|
||||
for (const place of uncached) {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) {
|
||||
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
cachedMap.set(place.id, { place_id: place.id, ...info });
|
||||
}
|
||||
// Nominatim rate limit: 1 req/sec
|
||||
if (uncached.indexOf(place) < uncached.length - 1) {
|
||||
await new Promise(r => setTimeout(r, 1100));
|
||||
}
|
||||
}
|
||||
|
||||
// Group by country → regions with place counts
|
||||
const regionMap: Record<string, Map<string, { code: string; name: string; placeCount: number }>> = {};
|
||||
for (const [, entry] of cachedMap) {
|
||||
if (!regionMap[entry.country_code]) regionMap[entry.country_code] = new Map();
|
||||
const existing = regionMap[entry.country_code].get(entry.region_code);
|
||||
if (existing) {
|
||||
existing.placeCount++;
|
||||
} else {
|
||||
regionMap[entry.country_code].set(entry.region_code, { code: entry.region_code, name: entry.region_name, placeCount: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]> = {};
|
||||
for (const [country, regions] of Object.entries(regionMap)) {
|
||||
result[country] = [...regions.values()];
|
||||
}
|
||||
|
||||
// Merge manually marked regions
|
||||
const manualRegions = listManuallyVisitedRegions(userId);
|
||||
for (const r of manualRegions) {
|
||||
if (!result[r.country_code]) result[r.country_code] = [];
|
||||
if (!result[r.country_code].find(x => x.code === r.region_code)) {
|
||||
result[r.country_code].push({ code: r.region_code, name: r.region_name, placeCount: 0, manuallyMarked: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { regions: result };
|
||||
}
|
||||
|
||||
// ── Bucket list CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,9 +31,7 @@ const MFA_BACKUP_CODE_COUNT = 10;
|
||||
const ADMIN_SETTINGS_KEYS = [
|
||||
'allow_registration', 'allowed_file_types', 'require_mfa',
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
||||
'notification_webhook_url', 'notification_channel',
|
||||
'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder',
|
||||
'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged',
|
||||
'notification_channels', 'admin_webhook_url',
|
||||
];
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
@@ -195,8 +193,10 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
|
||||
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
|
||||
const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value);
|
||||
const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value);
|
||||
const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl);
|
||||
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
|
||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
|
||||
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
|
||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||
|
||||
@@ -216,6 +216,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
notification_channel: notifChannel,
|
||||
notification_channels: activeChannels,
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
||||
dev_mode: process.env.NODE_ENV === 'development',
|
||||
@@ -676,7 +678,7 @@ export function getAppSettings(userId: number): { error?: string; status?: numbe
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
|
||||
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
|
||||
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url') ? '••••••••' : row.value;
|
||||
}
|
||||
return { data: result };
|
||||
}
|
||||
@@ -714,6 +716,8 @@ export function updateAppSettings(
|
||||
}
|
||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||
if (key === 'smtp_pass') val = encrypt_api_key(val);
|
||||
if (key === 'admin_webhook_url' && val === '••••••••') continue;
|
||||
if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||
}
|
||||
}
|
||||
@@ -722,11 +726,9 @@ export function updateAppSettings(
|
||||
|
||||
const summary: Record<string, unknown> = {};
|
||||
const smtpChanged = changedKeys.some(k => k.startsWith('smtp_'));
|
||||
const eventsChanged = changedKeys.some(k => k.startsWith('notify_'));
|
||||
if (changedKeys.includes('notification_channel')) summary.notification_channel = body.notification_channel;
|
||||
if (changedKeys.includes('notification_webhook_url')) summary.webhook_url_updated = true;
|
||||
if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels;
|
||||
if (changedKeys.includes('admin_webhook_url')) summary.admin_webhook_url_updated = true;
|
||||
if (smtpChanged) summary.smtp_settings_updated = true;
|
||||
if (eventsChanged) summary.notification_events_updated = true;
|
||||
if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration;
|
||||
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
|
||||
if (changedKeys.includes('require_mfa')) summary.require_mfa = body.require_mfa;
|
||||
@@ -736,7 +738,7 @@ export function updateAppSettings(
|
||||
debugDetails[k] = k === 'smtp_pass' ? '***' : body[k];
|
||||
}
|
||||
|
||||
const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder'];
|
||||
const notifRelated = ['notification_channels', 'smtp_host'];
|
||||
const shouldRestartScheduler = changedKeys.some(k => notifRelated.includes(k));
|
||||
if (shouldRestartScheduler) {
|
||||
startTripReminders();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import { getAction } from './inAppNotificationActions';
|
||||
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
@@ -11,6 +12,7 @@ interface BaseNotificationInput {
|
||||
scope: NotificationScope;
|
||||
target: number;
|
||||
sender_id: number | null;
|
||||
event_type?: NotifEventType;
|
||||
title_key: string;
|
||||
title_params?: Record<string, string>;
|
||||
text_key: string;
|
||||
@@ -61,7 +63,7 @@ interface NotificationRow {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||
export function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||
let userIds: number[] = [];
|
||||
|
||||
if (scope === 'trip') {
|
||||
@@ -93,7 +95,8 @@ function createNotification(input: NotificationInput): number[] {
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
const insertedIds: number[] = [];
|
||||
// Track inserted id → recipientId pairs (some recipients may be skipped by pref check)
|
||||
const insertedPairs: Array<{ id: number; recipientId: number }> = [];
|
||||
|
||||
const insert = db.transaction(() => {
|
||||
const stmt = db.prepare(`
|
||||
@@ -106,6 +109,11 @@ function createNotification(input: NotificationInput): number[] {
|
||||
`);
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
// Check per-user in-app preference if an event_type is provided
|
||||
if (input.event_type && !isEnabledForEvent(recipientId, input.event_type, 'inapp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let positiveTextKey: string | null = null;
|
||||
let negativeTextKey: string | null = null;
|
||||
let positiveCallback: string | null = null;
|
||||
@@ -130,7 +138,7 @@ function createNotification(input: NotificationInput): number[] {
|
||||
navigateTextKey, navigateTarget
|
||||
);
|
||||
|
||||
insertedIds.push(result.lastInsertRowid as number);
|
||||
insertedPairs.push({ id: result.lastInsertRowid as number, recipientId });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -142,9 +150,7 @@ function createNotification(input: NotificationInput): number[] {
|
||||
: null;
|
||||
|
||||
// Broadcast to each recipient
|
||||
for (let i = 0; i < insertedIds.length; i++) {
|
||||
const notificationId = insertedIds[i];
|
||||
const recipientId = recipients[i];
|
||||
for (const { id: notificationId, recipientId } of insertedPairs) {
|
||||
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow;
|
||||
if (!row) continue;
|
||||
|
||||
@@ -158,7 +164,66 @@ function createNotification(input: NotificationInput): number[] {
|
||||
});
|
||||
}
|
||||
|
||||
return insertedIds;
|
||||
return insertedPairs.map(p => p.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a single in-app notification for one pre-resolved recipient and broadcast via WebSocket.
|
||||
* Used by notificationService.send() which handles recipient resolution externally.
|
||||
*/
|
||||
export function createNotificationForRecipient(
|
||||
input: NotificationInput,
|
||||
recipientId: number,
|
||||
sender: { username: string; avatar: string | null } | null
|
||||
): number | null {
|
||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||
const textParams = JSON.stringify(input.text_params ?? {});
|
||||
|
||||
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 = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).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
|
||||
);
|
||||
|
||||
const notificationId = result.lastInsertRowid as number;
|
||||
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
broadcastToUser(recipientId, {
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return notificationId;
|
||||
}
|
||||
|
||||
function getNotifications(
|
||||
@@ -266,55 +331,6 @@ async function respondToBoolean(
|
||||
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,
|
||||
@@ -325,8 +341,6 @@ export {
|
||||
deleteNotification,
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
getPreferences,
|
||||
updatePreferences,
|
||||
};
|
||||
|
||||
export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };
|
||||
|
||||
@@ -1,40 +1,267 @@
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
export function getPreferences(userId: number) {
|
||||
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
|
||||
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);
|
||||
}
|
||||
return prefs;
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NotifChannel = 'email' | 'webhook' | 'inapp';
|
||||
|
||||
export type NotifEventType =
|
||||
| 'trip_invite'
|
||||
| 'booking_change'
|
||||
| 'trip_reminder'
|
||||
| 'vacay_invite'
|
||||
| 'photos_shared'
|
||||
| 'collab_message'
|
||||
| 'packing_tagged'
|
||||
| 'version_available';
|
||||
|
||||
export interface AvailableChannels {
|
||||
email: boolean;
|
||||
webhook: boolean;
|
||||
inapp: boolean;
|
||||
}
|
||||
|
||||
export function updatePreferences(
|
||||
userId: number,
|
||||
fields: {
|
||||
notify_trip_invite?: boolean;
|
||||
notify_booking_change?: boolean;
|
||||
notify_trip_reminder?: boolean;
|
||||
notify_webhook?: boolean;
|
||||
}
|
||||
) {
|
||||
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);
|
||||
// Which channels are implemented for each event type.
|
||||
// Only implemented combos show toggles in the user preferences UI.
|
||||
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
||||
trip_invite: ['inapp', 'email', 'webhook'],
|
||||
booking_change: ['inapp', 'email', 'webhook'],
|
||||
trip_reminder: ['inapp', 'email', 'webhook'],
|
||||
vacay_invite: ['inapp', 'email', 'webhook'],
|
||||
photos_shared: ['inapp', 'email', 'webhook'],
|
||||
collab_message: ['inapp', 'email', 'webhook'],
|
||||
packing_tagged: ['inapp', 'email', 'webhook'],
|
||||
version_available: ['inapp', 'email', 'webhook'],
|
||||
};
|
||||
|
||||
/** Events that target admins only (shown in admin panel, not in user settings). */
|
||||
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available']);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getAppSetting(key: string): string | null {
|
||||
return (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || null;
|
||||
}
|
||||
|
||||
// ── Active channels (admin-configured) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns which channels the admin has enabled (email and/or webhook).
|
||||
* Reads `notification_channels` (plural) with fallback to `notification_channel` (singular).
|
||||
* In-app is always considered active at the service level.
|
||||
*/
|
||||
export function getActiveChannels(): NotifChannel[] {
|
||||
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
|
||||
if (raw === 'none') return [];
|
||||
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which channels are configured (have valid credentials/URLs set).
|
||||
* In-app is always available. Email/webhook depend on configuration.
|
||||
*/
|
||||
export function getAvailableChannels(): AvailableChannels {
|
||||
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||
const hasWebhook = getActiveChannels().includes('webhook');
|
||||
return { email: hasSmtp, webhook: hasWebhook, inapp: true };
|
||||
}
|
||||
|
||||
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if the user has this event+channel enabled.
|
||||
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
|
||||
*/
|
||||
export function isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, channel) as { enabled: number } | undefined;
|
||||
return row === undefined || row.enabled === 1;
|
||||
}
|
||||
|
||||
// ── Preferences matrix ─────────────────────────────────────────────────────
|
||||
|
||||
export interface PreferencesMatrix {
|
||||
preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
|
||||
available_channels: AvailableChannels;
|
||||
event_types: NotifEventType[];
|
||||
implemented_combos: Record<NotifEventType, NotifChannel[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferences matrix for a user.
|
||||
* scope='user' — excludes admin-scoped events (for user settings page)
|
||||
* scope='admin' — returns only admin-scoped events (for admin notifications tab)
|
||||
*/
|
||||
export function getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix {
|
||||
const rows = db.prepare(
|
||||
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?'
|
||||
).all(userId) as Array<{ event_type: string; channel: string; enabled: number }>;
|
||||
|
||||
// Build a lookup from stored rows
|
||||
const stored: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
for (const row of rows) {
|
||||
if (!stored[row.event_type]) stored[row.event_type] = {};
|
||||
stored[row.event_type]![row.channel] = row.enabled === 1;
|
||||
}
|
||||
|
||||
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(
|
||||
fields.notify_trip_invite !== undefined ? (fields.notify_trip_invite ? 1 : 0) : null,
|
||||
fields.notify_booking_change !== undefined ? (fields.notify_booking_change ? 1 : 0) : null,
|
||||
fields.notify_trip_reminder !== undefined ? (fields.notify_trip_reminder ? 1 : 0) : null,
|
||||
fields.notify_webhook !== undefined ? (fields.notify_webhook ? 1 : 0) : null,
|
||||
userId
|
||||
// Build the full matrix with defaults (true when no row exists)
|
||||
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
|
||||
const allEvents = Object.keys(IMPLEMENTED_COMBOS) as NotifEventType[];
|
||||
|
||||
for (const eventType of allEvents) {
|
||||
const channels = IMPLEMENTED_COMBOS[eventType];
|
||||
preferences[eventType] = {};
|
||||
for (const channel of channels) {
|
||||
// Admin-scoped events use global settings for email/webhook
|
||||
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook')) {
|
||||
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
|
||||
} else {
|
||||
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter event types by scope
|
||||
const event_types = scope === 'admin'
|
||||
? allEvents.filter(e => ADMIN_SCOPED_EVENTS.has(e))
|
||||
: allEvents.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
|
||||
|
||||
// Available channels depend on scope
|
||||
let available_channels: AvailableChannels;
|
||||
if (scope === 'admin') {
|
||||
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
|
||||
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, inapp: true };
|
||||
} else {
|
||||
const activeChannels = getActiveChannels();
|
||||
available_channels = {
|
||||
email: activeChannels.includes('email'),
|
||||
webhook: activeChannels.includes('webhook'),
|
||||
inapp: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
preferences,
|
||||
available_channels,
|
||||
event_types,
|
||||
implemented_combos: IMPLEMENTED_COMBOS,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin global preferences (stored in app_settings) ─────────────────────
|
||||
|
||||
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook'];
|
||||
|
||||
/**
|
||||
* Returns the global admin preference for an event+channel.
|
||||
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
||||
* Defaults to true (enabled) when no row exists.
|
||||
*/
|
||||
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook'): boolean {
|
||||
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||
return val !== '0';
|
||||
}
|
||||
|
||||
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook', enabled: boolean): void {
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||
`admin_notif_pref_${event}_${channel}`,
|
||||
enabled ? '1' : '0'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preferences update ─────────────────────────────────────────────────────
|
||||
|
||||
// ── Shared helper for per-user channel preference upserts ─────────────────
|
||||
|
||||
function applyUserChannelPrefs(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>,
|
||||
upsert: ReturnType<typeof db.prepare>,
|
||||
del: ReturnType<typeof db.prepare>
|
||||
): void {
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (enabled) {
|
||||
// Remove explicit row — default is enabled
|
||||
del.run(userId, eventType, channel);
|
||||
} else {
|
||||
upsert.run(userId, eventType, channel, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update preferences from the matrix UI.
|
||||
* Inserts disabled rows (enabled=0) and removes rows that are enabled (default).
|
||||
*/
|
||||
export function setPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
const upsert = db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const del = db.prepare(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
db.transaction(() => applyUserChannelPrefs(userId, prefs, upsert, del))();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-update admin notification preferences.
|
||||
* email/webhook channels are stored globally in app_settings (not per-user).
|
||||
* inapp channel remains per-user in notification_channel_preferences.
|
||||
*/
|
||||
export function setAdminPreferences(
|
||||
userId: number,
|
||||
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||
): void {
|
||||
const upsert = db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const del = db.prepare(
|
||||
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
);
|
||||
|
||||
return db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
|
||||
// Split global (email/webhook) from per-user (inapp) prefs
|
||||
const globalPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
const userPrefs: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||
|
||||
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) {
|
||||
if (!globalPrefs[eventType]) globalPrefs[eventType] = {};
|
||||
globalPrefs[eventType]![channel] = enabled;
|
||||
} else {
|
||||
if (!userPrefs[eventType]) userPrefs[eventType] = {};
|
||||
userPrefs[eventType]![channel] = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply global prefs outside the transaction (they write to app_settings)
|
||||
for (const [eventType, channels] of Object.entries(globalPrefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-user (inapp) prefs in a transaction
|
||||
db.transaction(() => applyUserChannelPrefs(userId, userPrefs, upsert, del))();
|
||||
}
|
||||
|
||||
// ── SMTP availability helper (for authService) ─────────────────────────────
|
||||
|
||||
export function isSmtpConfigured(): boolean {
|
||||
return !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||
}
|
||||
|
||||
export function isWebhookConfigured(): boolean {
|
||||
return getActiveChannels().includes('webhook');
|
||||
}
|
||||
|
||||
285
server/src/services/notificationService.ts
Normal file
285
server/src/services/notificationService.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { db } from '../db/database';
|
||||
import { logDebug, logError } from './auditLog';
|
||||
import {
|
||||
getActiveChannels,
|
||||
isEnabledForEvent,
|
||||
getAdminGlobalPref,
|
||||
isSmtpConfigured,
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
type NotifEventType,
|
||||
} from './notificationPreferencesService';
|
||||
import {
|
||||
getEventText,
|
||||
sendEmail,
|
||||
sendWebhook,
|
||||
getUserEmail,
|
||||
getUserLanguage,
|
||||
getUserWebhookUrl,
|
||||
getAdminWebhookUrl,
|
||||
getAppUrl,
|
||||
} from './notifications';
|
||||
import {
|
||||
resolveRecipients,
|
||||
createNotificationForRecipient,
|
||||
type NotificationInput,
|
||||
} from './inAppNotifications';
|
||||
|
||||
// ── Event config map ───────────────────────────────────────────────────────
|
||||
|
||||
interface EventNotifConfig {
|
||||
inAppType: 'simple' | 'navigate';
|
||||
titleKey: string;
|
||||
textKey: string;
|
||||
navigateTextKey?: string;
|
||||
navigateTarget: (params: Record<string, string>) => string | null;
|
||||
}
|
||||
|
||||
const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
// ── Dev-only test events ──────────────────────────────────────────────────
|
||||
test_simple: {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.simple.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_boolean: {
|
||||
inAppType: 'simple', // overridden by inApp.type at call site
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.boolean.text',
|
||||
navigateTarget: () => null,
|
||||
},
|
||||
test_navigate: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.test.title',
|
||||
textKey: 'notif.test.navigate.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: () => '/dashboard',
|
||||
},
|
||||
// ── Production events ─────────────────────────────────────────────────────
|
||||
trip_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_invite.title',
|
||||
textKey: 'notif.trip_invite.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
booking_change: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.booking_change.title',
|
||||
textKey: 'notif.booking_change.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
trip_reminder: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.trip_reminder.title',
|
||||
textKey: 'notif.trip_reminder.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
vacay_invite: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.vacay_invite.title',
|
||||
textKey: 'notif.vacay_invite.text',
|
||||
navigateTextKey: 'notif.action.view_vacay',
|
||||
navigateTarget: p => (p.planId ? `/vacay/${p.planId}` : null),
|
||||
},
|
||||
photos_shared: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.photos_shared.title',
|
||||
textKey: 'notif.photos_shared.text',
|
||||
navigateTextKey: 'notif.action.view_trip',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
collab_message: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.collab_message.title',
|
||||
textKey: 'notif.collab_message.text',
|
||||
navigateTextKey: 'notif.action.view_collab',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
packing_tagged: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.packing_tagged.title',
|
||||
textKey: 'notif.packing_tagged.text',
|
||||
navigateTextKey: 'notif.action.view_packing',
|
||||
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||
},
|
||||
version_available: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.version_available.title',
|
||||
textKey: 'notif.version_available.text',
|
||||
navigateTextKey: 'notif.action.view_admin',
|
||||
navigateTarget: () => '/admin',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Fallback config for unknown event types ────────────────────────────────
|
||||
|
||||
const FALLBACK_EVENT_CONFIG: EventNotifConfig = {
|
||||
inAppType: 'simple',
|
||||
titleKey: 'notif.generic.title',
|
||||
textKey: 'notif.generic.text',
|
||||
navigateTarget: () => null,
|
||||
};
|
||||
|
||||
// ── Unified send() API ─────────────────────────────────────────────────────
|
||||
|
||||
export interface NotificationPayload {
|
||||
event: NotifEventType;
|
||||
actorId: number | null;
|
||||
params: Record<string, string>;
|
||||
scope: 'trip' | 'user' | 'admin';
|
||||
targetId: number; // tripId for trip scope, userId for user scope, 0 for admin
|
||||
/** Optional in-app overrides (e.g. boolean type with callbacks) */
|
||||
inApp?: {
|
||||
type?: 'simple' | 'boolean' | 'navigate';
|
||||
positiveTextKey?: string;
|
||||
negativeTextKey?: string;
|
||||
positiveCallback?: { action: string; payload: Record<string, unknown> };
|
||||
negativeCallback?: { action: string; payload: Record<string, unknown> };
|
||||
navigateTarget?: string; // override the auto-generated navigate target
|
||||
};
|
||||
}
|
||||
|
||||
export async function send(payload: NotificationPayload): Promise<void> {
|
||||
const { event, actorId, params, scope, targetId, inApp } = payload;
|
||||
|
||||
// Resolve recipients based on scope
|
||||
const recipients = resolveRecipients(scope, targetId, actorId);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||
if (!configEntry) {
|
||||
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||
if (process.env.NODE_ENV === 'development' && actorId != null) {
|
||||
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
|
||||
createNotificationForRecipient({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: actorId,
|
||||
sender_id: null,
|
||||
title_key: 'notif.dev.unknown_event.title',
|
||||
text_key: 'notif.dev.unknown_event.text',
|
||||
text_params: { event },
|
||||
}, actorId, devSender);
|
||||
}
|
||||
}
|
||||
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
|
||||
const activeChannels = getActiveChannels();
|
||||
const appUrl = getAppUrl();
|
||||
|
||||
// Build navigate target (used by email/webhook CTA and in-app navigate)
|
||||
const navigateTarget = inApp?.navigateTarget ?? config.navigateTarget(params);
|
||||
const fullLink = navigateTarget ? `${appUrl}${navigateTarget}` : undefined;
|
||||
|
||||
// Fetch sender info once for in-app WS payloads
|
||||
const sender = actorId
|
||||
? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null
|
||||
: null;
|
||||
|
||||
logDebug(`notificationService.send event=${event} scope=${scope} targetId=${targetId} recipients=${recipients.length} channels=inapp,${activeChannels.join(',')}`);
|
||||
|
||||
// Dispatch to each recipient in parallel
|
||||
await Promise.all(recipients.map(async (recipientId) => {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// ── In-app ──────────────────────────────────────────────────────────
|
||||
if (isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||
const inAppType = inApp?.type ?? config.inAppType;
|
||||
let notifInput: NotificationInput;
|
||||
|
||||
if (inAppType === 'boolean' && inApp?.positiveCallback && inApp?.negativeCallback) {
|
||||
notifInput = {
|
||||
type: 'boolean',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
positive_text_key: inApp.positiveTextKey ?? 'notif.action.accept',
|
||||
negative_text_key: inApp.negativeTextKey ?? 'notif.action.decline',
|
||||
positive_callback: inApp.positiveCallback,
|
||||
negative_callback: inApp.negativeCallback,
|
||||
};
|
||||
} else if (inAppType === 'navigate' && navigateTarget) {
|
||||
notifInput = {
|
||||
type: 'navigate',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
navigate_text_key: config.navigateTextKey ?? 'notif.action.view',
|
||||
navigate_target: navigateTarget,
|
||||
};
|
||||
} else {
|
||||
notifInput = {
|
||||
type: 'simple',
|
||||
scope,
|
||||
target: targetId,
|
||||
sender_id: actorId,
|
||||
event_type: event,
|
||||
title_key: config.titleKey,
|
||||
title_params: params,
|
||||
text_key: config.textKey,
|
||||
text_params: params,
|
||||
};
|
||||
}
|
||||
|
||||
promises.push(
|
||||
Promise.resolve().then(() => createNotificationForRecipient(notifInput, recipientId, sender ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Email ────────────────────────────────────────────────────────────
|
||||
// Admin-scoped events: use global pref + SMTP check (bypass notification_channels toggle)
|
||||
// Regular events: use active channels + per-user pref
|
||||
const emailEnabled = ADMIN_SCOPED_EVENTS.has(event)
|
||||
? isSmtpConfigured() && getAdminGlobalPref(event, 'email')
|
||||
: activeChannels.includes('email') && isEnabledForEvent(recipientId, event, 'email');
|
||||
|
||||
if (emailEnabled) {
|
||||
const email = getUserEmail(recipientId);
|
||||
if (email) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
promises.push(sendEmail(email, title, body, recipientId, navigateTarget ?? undefined));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Webhook (per-user) — skip for admin-scoped events (handled globally below) ──
|
||||
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('webhook') && isEnabledForEvent(recipientId, event, 'webhook')) {
|
||||
const webhookUrl = getUserWebhookUrl(recipientId);
|
||||
if (webhookUrl) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
promises.push(sendWebhook(webhookUrl, { event, title, body, tripName: params.trip, link: fullLink }));
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logError(`notificationService.send channel dispatch failed event=${event} recipient=${recipientId}: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// ── Admin webhook (scope: admin) — global, respects global pref ──────
|
||||
if (scope === 'admin' && getAdminGlobalPref(event, 'webhook')) {
|
||||
const adminWebhookUrl = getAdminWebhookUrl();
|
||||
if (adminWebhookUrl) {
|
||||
const { title, body } = getEventText('en', event, params);
|
||||
await sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch((err: unknown) => {
|
||||
logError(`notificationService.send admin webhook failed event=${event}: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,11 @@ import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { logInfo, logDebug, logError } from './auditLog';
|
||||
import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type EventType = 'trip_invite' | 'booking_change' | 'trip_reminder' | 'vacay_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged';
|
||||
|
||||
interface NotificationPayload {
|
||||
userId: number;
|
||||
event: EventType;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
import type { NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
@@ -23,6 +18,17 @@ interface SmtpConfig {
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
// ── HTML escaping ──────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── Settings helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function getAppSetting(key: string): string | null {
|
||||
@@ -39,11 +45,8 @@ function getSmtpConfig(): SmtpConfig | null {
|
||||
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
|
||||
}
|
||||
|
||||
function getWebhookUrl(): string | null {
|
||||
return process.env.NOTIFICATION_WEBHOOK_URL || getAppSetting('notification_webhook_url');
|
||||
}
|
||||
|
||||
function getAppUrl(): string {
|
||||
// Exported for use by notificationService
|
||||
export function getAppUrl(): string {
|
||||
if (process.env.APP_URL) return process.env.APP_URL;
|
||||
const origins = process.env.ALLOWED_ORIGINS;
|
||||
if (origins) {
|
||||
@@ -54,31 +57,23 @@ function getAppUrl(): string {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
function getUserEmail(userId: number): string | null {
|
||||
export function getUserEmail(userId: number): string | null {
|
||||
return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null;
|
||||
}
|
||||
|
||||
function getUserLanguage(userId: number): string {
|
||||
export function getUserLanguage(userId: number): string {
|
||||
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
|
||||
}
|
||||
|
||||
function getAdminEventEnabled(event: EventType): boolean {
|
||||
const prefKey = EVENT_PREF_MAP[event];
|
||||
if (!prefKey) return true;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(prefKey) as { value: string } | undefined;
|
||||
return !row || row.value !== 'false';
|
||||
export function getUserWebhookUrl(userId: number): string | null {
|
||||
const value = (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as { value: string } | undefined)?.value || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
// Event → preference column mapping
|
||||
const EVENT_PREF_MAP: Record<EventType, string> = {
|
||||
trip_invite: 'notify_trip_invite',
|
||||
booking_change: 'notify_booking_change',
|
||||
trip_reminder: 'notify_trip_reminder',
|
||||
vacay_invite: 'notify_vacay_invite',
|
||||
photos_shared: 'notify_photos_shared',
|
||||
collab_message: 'notify_collab_message',
|
||||
packing_tagged: 'notify_packing_tagged',
|
||||
};
|
||||
export function getAdminWebhookUrl(): string | null {
|
||||
const value = getAppSetting('admin_webhook_url') || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
// ── Email i18n strings ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -99,7 +94,7 @@ const I18N: Record<string, EmailStrings> = {
|
||||
interface EventText { title: string; body: string }
|
||||
type EventTextFn = (params: Record<string, string>) => EventText
|
||||
|
||||
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||
en: {
|
||||
trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
|
||||
@@ -108,6 +103,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }),
|
||||
},
|
||||
de: {
|
||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
|
||||
@@ -117,6 +113,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
|
||||
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
||||
version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }),
|
||||
},
|
||||
fr: {
|
||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
|
||||
@@ -126,6 +123,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }),
|
||||
},
|
||||
es: {
|
||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
|
||||
@@ -135,6 +133,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }),
|
||||
},
|
||||
nl: {
|
||||
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
|
||||
@@ -144,6 +143,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }),
|
||||
},
|
||||
ru: {
|
||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
|
||||
@@ -153,6 +153,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }),
|
||||
},
|
||||
zh: {
|
||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
|
||||
@@ -162,6 +163,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
|
||||
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
||||
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }),
|
||||
},
|
||||
ar: {
|
||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
|
||||
@@ -171,21 +173,76 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }),
|
||||
},
|
||||
br: {
|
||||
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nova reserva: ${p.booking}`, body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Lembrete: ${p.trip}`, body: `Sua viagem "${p.trip}" está chegando!` }),
|
||||
vacay_invite: p => ({ title: 'Convite Vacay Fusion', body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.` }),
|
||||
photos_shared: p => ({ title: `${p.count} fotos compartilhadas`, body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }),
|
||||
},
|
||||
cs: {
|
||||
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nová rezervace: ${p.booking}`, body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Připomínka výletu: ${p.trip}`, body: `Váš výlet "${p.trip}" se blíží!` }),
|
||||
vacay_invite: p => ({ title: 'Pozvánka Vacay Fusion', body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.` }),
|
||||
photos_shared: p => ({ title: `${p.count} sdílených fotek`, body: `${p.actor} sdílel ${p.count} foto v "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }),
|
||||
},
|
||||
hu: {
|
||||
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
|
||||
booking_change: p => ({ title: `Új foglalás: ${p.booking}`, body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.` }),
|
||||
trip_reminder: p => ({ title: `Utazás emlékeztető: ${p.trip}`, body: `A(z) "${p.trip}" utazás hamarosan kezdődik!` }),
|
||||
vacay_invite: p => ({ title: 'Vacay Fusion meghívó', body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.` }),
|
||||
photos_shared: p => ({ title: `${p.count} fotó megosztva`, body: `${p.actor} ${p.count} fotót osztott meg a(z) "${p.trip}" utazásban.` }),
|
||||
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }),
|
||||
version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }),
|
||||
},
|
||||
it: {
|
||||
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nuova prenotazione: ${p.booking}`, body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Promemoria viaggio: ${p.trip}`, body: `Il tuo viaggio "${p.trip}" si avvicina!` }),
|
||||
vacay_invite: p => ({ title: 'Invito Vacay Fusion', body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.` }),
|
||||
photos_shared: p => ({ title: `${p.count} foto condivise`, body: `${p.actor} ha condiviso ${p.count} foto in "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }),
|
||||
},
|
||||
pl: {
|
||||
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
|
||||
booking_change: p => ({ title: `Nowa rezerwacja: ${p.booking}`, body: `${p.actor} dodał rezerwację "${p.booking}" (${p.type}) do "${p.trip}".` }),
|
||||
trip_reminder: p => ({ title: `Przypomnienie o podróży: ${p.trip}`, body: `Twoja podróż "${p.trip}" zbliża się!` }),
|
||||
vacay_invite: p => ({ title: 'Zaproszenie Vacay Fusion', body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.` }),
|
||||
photos_shared: p => ({ title: `${p.count} zdjęć udostępnionych`, body: `${p.actor} udostępnił ${p.count} zdjęcie/zdjęcia w "${p.trip}".` }),
|
||||
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||
packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }),
|
||||
version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }),
|
||||
},
|
||||
};
|
||||
|
||||
// Get localized event text
|
||||
export function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
|
||||
export function getEventText(lang: string, event: NotifEventType, params: Record<string, string>): EventText {
|
||||
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
||||
return texts[event](params);
|
||||
const fn = texts[event] ?? EVENT_TEXTS.en[event];
|
||||
if (!fn) return { title: event, body: '' };
|
||||
return fn(params);
|
||||
}
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string): string {
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = appUrl || '#';
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
|
||||
const safeSubject = escapeHtml(subject);
|
||||
const safeBody = escapeHtml(body);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -202,9 +259,9 @@ export function buildEmailHtml(subject: string, body: string, lang: string): str
|
||||
</td></tr>
|
||||
<!-- Content -->
|
||||
<tr><td style="padding: 32px 32px 16px;">
|
||||
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${subject}</h1>
|
||||
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${safeSubject}</h1>
|
||||
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
|
||||
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${body}</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
|
||||
</td></tr>
|
||||
<!-- CTA -->
|
||||
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
|
||||
@@ -224,7 +281,7 @@ export function buildEmailHtml(subject: string, body: string, lang: string): str
|
||||
|
||||
// ── Send functions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise<boolean> {
|
||||
export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise<boolean> {
|
||||
const config = getSmtpConfig();
|
||||
if (!config) return false;
|
||||
|
||||
@@ -245,7 +302,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
||||
to,
|
||||
subject: `TREK — ${subject}`,
|
||||
text: body,
|
||||
html: buildEmailHtml(subject, body, lang),
|
||||
html: buildEmailHtml(subject, body, lang, navigateTarget),
|
||||
});
|
||||
logInfo(`Email sent to=${to} subject="${subject}"`);
|
||||
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
|
||||
@@ -256,7 +313,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
|
||||
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): string {
|
||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||
|
||||
@@ -265,6 +322,7 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
|
||||
embeds: [{
|
||||
title: `📍 ${payload.title}`,
|
||||
description: payload.body,
|
||||
url: payload.link,
|
||||
color: 0x3b82f6,
|
||||
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -274,24 +332,32 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
|
||||
|
||||
if (isSlack) {
|
||||
const trip = payload.tripName ? ` • _${payload.tripName}_` : '';
|
||||
const link = payload.link ? `\n<${payload.link}|Open in TREK>` : '';
|
||||
return JSON.stringify({
|
||||
text: `*${payload.title}*\n${payload.body}${trip}`,
|
||||
text: `*${payload.title}*\n${payload.body}${trip}${link}`,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
|
||||
}
|
||||
|
||||
async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise<boolean> {
|
||||
const url = getWebhookUrl();
|
||||
export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
logError(`Webhook blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = createPinnedAgent(ssrf.resolvedIp!, new URL(url).protocol);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: buildWebhookBody(url, payload),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
agent,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -309,56 +375,6 @@ async function sendWebhook(payload: { event: string; title: string; body: string
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getNotificationChannel(): string {
|
||||
return getAppSetting('notification_channel') || 'none';
|
||||
}
|
||||
|
||||
export async function notify(payload: NotificationPayload): Promise<void> {
|
||||
const channel = getNotificationChannel();
|
||||
if (channel === 'none') return;
|
||||
|
||||
if (!getAdminEventEnabled(payload.event)) return;
|
||||
|
||||
const lang = getUserLanguage(payload.userId);
|
||||
const { title, body } = getEventText(lang, payload.event, payload.params);
|
||||
|
||||
logDebug(`Notification event=${payload.event} channel=${channel} userId=${payload.userId} params=${JSON.stringify(payload.params)}`);
|
||||
|
||||
if (channel === 'email') {
|
||||
const email = getUserEmail(payload.userId);
|
||||
if (email) await sendEmail(email, title, body, payload.userId);
|
||||
} else if (channel === 'webhook') {
|
||||
await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record<string, string>): Promise<void> {
|
||||
const channel = getNotificationChannel();
|
||||
if (channel === 'none') return;
|
||||
if (!getAdminEventEnabled(event)) return;
|
||||
|
||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
|
||||
if (!trip) return;
|
||||
|
||||
if (channel === 'webhook') {
|
||||
const lang = getUserLanguage(actorUserId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
logDebug(`notifyTripMembers event=${event} channel=webhook tripId=${tripId} actor=${actorUserId}`);
|
||||
await sendWebhook({ event, title, body, tripName: params.trip });
|
||||
return;
|
||||
}
|
||||
|
||||
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
|
||||
const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
|
||||
const unique = [...new Set(allIds)];
|
||||
|
||||
for (const userId of unique) {
|
||||
await notify({ userId, event, params });
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
|
||||
@@ -368,11 +384,12 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?:
|
||||
}
|
||||
}
|
||||
|
||||
export async function testWebhook(): Promise<{ success: boolean; error?: string }> {
|
||||
export async function testWebhook(url: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const sent = await sendWebhook({ event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
|
||||
return sent ? { success: true } : { success: false, error: 'Webhook URL not configured' };
|
||||
const sent = await sendWebhook(url, { event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send webhook' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { db } from '../db/database';
|
||||
import { maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url']);
|
||||
|
||||
export function getUserSettings(userId: number): Record<string, unknown> {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
|
||||
settings[row.key] = row.value ? '••••••••' : '';
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
settings[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
@@ -13,12 +20,17 @@ export function getUserSettings(userId: number): Record<string, unknown> {
|
||||
return settings;
|
||||
}
|
||||
|
||||
function serializeValue(key: string, value: unknown): string {
|
||||
const raw = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
if (ENCRYPTED_SETTING_KEYS.has(key)) return maybe_encrypt_api_key(raw) ?? raw;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function upsertSetting(userId: number, key: string, value: unknown) {
|
||||
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
db.prepare(`
|
||||
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
|
||||
`).run(userId, key, serialized);
|
||||
`).run(userId, key, serializeValue(key, value));
|
||||
}
|
||||
|
||||
export function bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
|
||||
@@ -29,8 +41,7 @@ export function bulkUpsertSettings(userId: number, settings: Record<string, unkn
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||
upsert.run(userId, key, serialized);
|
||||
upsert.run(userId, key, serializeValue(key, value));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
|
||||
@@ -342,8 +342,8 @@ export function sendInvite(planId: number, inviterId: number, inviterUsername: s
|
||||
} catch { /* websocket not available */ }
|
||||
|
||||
// Notify invited user
|
||||
import('../services/notifications').then(({ notify }) => {
|
||||
notify({ userId: targetUserId, event: 'vacay_invite', params: { actor: inviterEmail } }).catch(() => {});
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {});
|
||||
});
|
||||
|
||||
return {};
|
||||
|
||||
@@ -112,10 +112,15 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
*/
|
||||
export function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent {
|
||||
const options = {
|
||||
lookup: (_hostname: string, _opts: unknown, callback: (err: Error | null, addr: string, family: number) => void) => {
|
||||
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
|
||||
// Determine address family from IP format
|
||||
const family = resolvedIp.includes(':') ? 6 : 4;
|
||||
callback(null, resolvedIp, family);
|
||||
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
|
||||
if (opts && opts.all) {
|
||||
callback(null, [{ address: resolvedIp, family }]);
|
||||
} else {
|
||||
callback(null, resolvedIp, family);
|
||||
}
|
||||
},
|
||||
};
|
||||
return protocol === 'https:' ? new https.Agent(options) : new http.Agent(options);
|
||||
|
||||
@@ -480,3 +480,29 @@ export function createInviteToken(
|
||||
).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy);
|
||||
return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Upsert a key/value pair into app_settings. */
|
||||
export function setAppSetting(db: Database.Database, key: string, value: string): void {
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
}
|
||||
|
||||
/** Set the active notification channels (e.g. 'email', 'webhook', 'email,webhook', 'none'). */
|
||||
export function setNotificationChannels(db: Database.Database, channels: string): void {
|
||||
setAppSetting(db, 'notification_channels', channels);
|
||||
}
|
||||
|
||||
/** Explicitly disable a per-user notification preference for a given event+channel combo. */
|
||||
export function disableNotificationPref(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
eventType: string,
|
||||
channel: string
|
||||
): void {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)'
|
||||
).run(userId, eventType, channel);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const RESET_TABLES = [
|
||||
'vacay_plans',
|
||||
'atlas_visited_countries',
|
||||
'atlas_bucket_list',
|
||||
'notification_channel_preferences',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
'user_settings',
|
||||
|
||||
@@ -39,12 +39,13 @@ vi.mock('../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -154,6 +155,137 @@ describe('In-app notifications', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// New preferences matrix API (NROUTE series)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/notifications/preferences — matrix format', () => {
|
||||
it('NROUTE-002 — returns preferences, available_channels, event_types, implemented_combos', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('preferences');
|
||||
expect(res.body).toHaveProperty('available_channels');
|
||||
expect(res.body).toHaveProperty('event_types');
|
||||
expect(res.body).toHaveProperty('implemented_combos');
|
||||
expect(res.body.available_channels.inapp).toBe(true);
|
||||
});
|
||||
|
||||
it('NROUTE-003 — regular user does not see version_available in event_types', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).not.toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-004 — user preferences endpoint excludes version_available even for admins', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).not.toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-004b — admin notification preferences endpoint returns version_available', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-005 — all preferences default to true for new user with no stored prefs', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const { preferences } = res.body;
|
||||
for (const [, channels] of Object.entries(preferences)) {
|
||||
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
|
||||
expect(enabled).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/notifications/preferences — matrix format', () => {
|
||||
it('NROUTE-007 — disabling a preference persists and is reflected in subsequent GET', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const putRes = await request(app)
|
||||
.put('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_invite: { email: false } });
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
});
|
||||
|
||||
it('NROUTE-008 — re-enabling a preference restores default state', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_invite: { email: true } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.preferences['trip_invite']['email']).toBe(true);
|
||||
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NROUTE-009 — partial update does not affect other preferences', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'booking_change', 'email');
|
||||
|
||||
await request(app)
|
||||
.put('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_invite: { email: false } });
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.preferences['booking_change']['email']).toBe(false);
|
||||
expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
expect(getRes.body.preferences['trip_reminder']['email']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('implemented_combos — in-app channel coverage', () => {
|
||||
it('NROUTE-010 — implemented_combos includes inapp for all event types', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const { implemented_combos } = res.body as { implemented_combos: Record<string, string[]> };
|
||||
const eventTypes = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
|
||||
for (const event of eventTypes) {
|
||||
expect(implemented_combos[event], `${event} should support inapp`).toContain('inapp');
|
||||
expect(implemented_combos[event], `${event} should support email`).toContain('email');
|
||||
expect(implemented_combos[event], `${event} should support webhook`).toContain('webhook');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification test endpoints', () => {
|
||||
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
@@ -165,13 +297,244 @@ describe('Notification test endpoints', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('NOTIF-006 — POST /api/notifications/test-webhook requires admin', async () => {
|
||||
it('NOTIF-006 — POST /api/notifications/test-webhook returns 400 when url is missing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-webhook')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NOTIF-006b — POST /api/notifications/test-webhook returns 400 for invalid URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-webhook')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'not-a-url' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helper: insert a boolean notification directly into the DB
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBooleanNotification(recipientId: number): number {
|
||||
const result = testDb.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
|
||||
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||
)
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function insertSimpleNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params
|
||||
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POST /in-app/:id/respond
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/notifications/in-app/:id/respond', () => {
|
||||
it('NROUTE-011 — valid positive response returns success and updated notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'positive' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.notification).toBeDefined();
|
||||
expect(res.body.notification.response).toBe('positive');
|
||||
});
|
||||
|
||||
it('NROUTE-012 — invalid response value returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'maybe' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NROUTE-013 — response on non-existent notification returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/in-app/99999/respond')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'positive' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NROUTE-014 — double response returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'positive' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ response: 'negative' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/already responded/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PUT /api/admin/notification-preferences
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PUT /api/admin/notification-preferences', () => {
|
||||
it('NROUTE-015 — admin can disable email for version_available, persists in GET', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
|
||||
const putRes = await request(app)
|
||||
.put('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ version_available: { email: false } });
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body.preferences['version_available']['email']).toBe(false);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body.preferences['version_available']['email']).toBe(false);
|
||||
});
|
||||
|
||||
it('NROUTE-016 — non-admin is rejected with 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ version_available: { email: false } });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-app CRUD with actual notification data
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('In-app notifications — CRUD with data', () => {
|
||||
it('NROUTE-017 — GET /in-app returns created notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notifications.length).toBe(2);
|
||||
expect(res.body.total).toBe(2);
|
||||
expect(res.body.unread_count).toBe(2);
|
||||
});
|
||||
|
||||
it('NROUTE-018 — unread count reflects actual unread notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app/unread-count')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
});
|
||||
|
||||
it('NROUTE-019 — mark-read on existing notification succeeds and decrements unread count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const markRes = await request(app)
|
||||
.put(`/api/notifications/in-app/${id}/read`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(markRes.status).toBe(200);
|
||||
expect(markRes.body.success).toBe(true);
|
||||
|
||||
const countRes = await request(app)
|
||||
.get('/api/notifications/in-app/unread-count')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(countRes.body.count).toBe(0);
|
||||
});
|
||||
|
||||
it('NROUTE-020 — mark-unread on a read notification succeeds', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
// Mark read first
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notifications/in-app/${id}/unread`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(id) as { is_read: number };
|
||||
expect(row.is_read).toBe(0);
|
||||
});
|
||||
|
||||
it('NROUTE-021 — DELETE on existing notification removes it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/notifications/in-app/${id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NROUTE-022 — unread_only=true filter returns only unread notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id1 = insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
// Mark first one read
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id1);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app?unread_only=true')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notifications.length).toBe(1);
|
||||
expect(res.body.notifications[0].is_read).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
294
server/tests/unit/services/inAppNotificationPrefs.test.ts
Normal file
294
server/tests/unit/services/inAppNotificationPrefs.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Unit tests for in-app notification preference filtering in createNotification().
|
||||
* Covers INOTIF-001 to INOTIF-004.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Mock WebSocket broadcast — must use vi.hoisted() so broadcastMock is available
|
||||
// when the vi.mock factory is evaluated (factories are hoisted before const declarations)
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
|
||||
import { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// createNotification — preference filtering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createNotification — preference filtering', () => {
|
||||
it('INOTIF-001 — notification without event_type is delivered to all recipients (backward compat)', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
// The admin scope targets all admins — create a second admin as the sender
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
|
||||
// Send to a specific user (user scope) without event_type
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
// no event_type
|
||||
});
|
||||
|
||||
expect(ids.length).toBe(1);
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||
expect(row).toBeDefined();
|
||||
// Also verify the admin who disabled all prefs still gets messages without event_type
|
||||
disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp');
|
||||
// admin still gets this since no event_type check
|
||||
const adminIds = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: admin.id,
|
||||
sender_id: sender.id,
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
expect(adminIds.length).toBe(1);
|
||||
});
|
||||
|
||||
it('INOTIF-002 — notification with event_type skips recipients who have disabled that event on inapp', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient1 } = createUser(testDb);
|
||||
const { user: recipient2 } = createUser(testDb);
|
||||
|
||||
// recipient2 has disabled inapp for trip_invite
|
||||
disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp');
|
||||
|
||||
// Use a trip to target both members
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);
|
||||
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: tripId,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
|
||||
// sender excluded, recipient1 included, recipient2 skipped (disabled pref)
|
||||
expect(ids.length).toBe(1);
|
||||
const r1 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient1.id);
|
||||
const r2 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient2.id);
|
||||
expect(r1).toBeDefined();
|
||||
expect(r2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
|
||||
// No preferences stored for recipient — should default to enabled
|
||||
const ids = createNotification({
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notifications.test.title',
|
||||
text_key: 'notifications.test.text',
|
||||
});
|
||||
|
||||
expect(ids.length).toBe(1);
|
||||
const row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', () => {
|
||||
const { user: sender } = createAdmin(testDb);
|
||||
const { user: recipient } = createUser(testDb);
|
||||
|
||||
const id = createNotificationForRecipient(
|
||||
{
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: recipient.id,
|
||||
sender_id: sender.id,
|
||||
event_type: 'trip_invite',
|
||||
title_key: 'notif.trip_invite.title',
|
||||
text_key: 'notif.trip_invite.text',
|
||||
navigate_text_key: 'notif.action.view_trip',
|
||||
navigate_target: '/trips/99',
|
||||
},
|
||||
recipient.id,
|
||||
{ username: 'admin', avatar: null }
|
||||
);
|
||||
|
||||
expect(id).toBeTypeOf('number');
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.recipient_id).toBe(recipient.id);
|
||||
expect(row!.navigate_target).toBe('/trips/99');
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(broadcastMock.mock.calls[0][0]).toBe(recipient.id);
|
||||
});
|
||||
|
||||
it('INOTIF-004 — admin-scope version_available only reaches admins with enabled pref', () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
|
||||
// admin2 disables version_available inapp notifications
|
||||
disableNotificationPref(testDb, admin2.id, 'version_available', 'inapp');
|
||||
|
||||
const ids = createNotification({
|
||||
type: 'navigate',
|
||||
scope: 'admin',
|
||||
target: 0,
|
||||
sender_id: null,
|
||||
event_type: 'version_available',
|
||||
title_key: 'notifications.versionAvailable.title',
|
||||
text_key: 'notifications.versionAvailable.text',
|
||||
navigate_text_key: 'notifications.versionAvailable.button',
|
||||
navigate_target: '/admin',
|
||||
});
|
||||
|
||||
// Only admin1 should receive it
|
||||
expect(ids.length).toBe(1);
|
||||
const admin1Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin1.id);
|
||||
const admin2Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin2.id);
|
||||
expect(admin1Row).toBeDefined();
|
||||
expect(admin2Row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// respondToBoolean
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBooleanNotification(recipientId: number, senderId: number | null = null): number {
|
||||
const result = testDb.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
|
||||
) VALUES ('boolean', 'user', ?, ?, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||
)
|
||||
`).run(recipientId, senderId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function insertSimpleNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params
|
||||
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
describe('respondToBoolean', () => {
|
||||
it('INOTIF-005 — positive response sets response=positive, marks read, broadcasts update', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.notification).toBeDefined();
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as any;
|
||||
expect(row.response).toBe('positive');
|
||||
expect(row.is_read).toBe(1);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(user.id, expect.objectContaining({ type: 'notification:updated' }));
|
||||
});
|
||||
|
||||
it('INOTIF-006 — negative response sets response=negative', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'negative');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(id) as any;
|
||||
expect(row.response).toBe('negative');
|
||||
});
|
||||
|
||||
it('INOTIF-007 — double-response prevention returns error on second call', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
|
||||
await respondToBoolean(id, user.id, 'positive');
|
||||
const result = await respondToBoolean(id, user.id, 'negative');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/already responded/i);
|
||||
});
|
||||
|
||||
it('INOTIF-008 — response on a simple notification returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not a boolean/i);
|
||||
});
|
||||
|
||||
it('INOTIF-009 — response on a non-existent notification returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = await respondToBoolean(99999, user.id, 'positive');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('INOTIF-010 — response on notification belonging to another user returns error', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const id = insertBooleanNotification(owner.id);
|
||||
|
||||
const result = await respondToBoolean(id, other.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
234
server/tests/unit/services/migration.test.ts
Normal file
234
server/tests/unit/services/migration.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Unit tests for migration 69 (normalized notification preferences).
|
||||
* Covers MIGR-001 to MIGR-004.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
|
||||
function buildFreshDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all migrations up to (but NOT including) migration 69, then return the db.
|
||||
* This allows us to set up old-schema data and test that migration 69 handles it.
|
||||
*
|
||||
* We do this by running only the schema tables that existed before migration 69,
|
||||
* seeding old data, then running migration 69 in isolation.
|
||||
*/
|
||||
function setupPreMigration69Db() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create schema_version and users table (bare minimum)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT INTO schema_version (version) VALUES (0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
notify_trip_invite INTEGER DEFAULT 1,
|
||||
notify_booking_change INTEGER DEFAULT 1,
|
||||
notify_trip_reminder INTEGER DEFAULT 1,
|
||||
notify_vacay_invite INTEGER DEFAULT 1,
|
||||
notify_photos_shared INTEGER DEFAULT 1,
|
||||
notify_collab_message INTEGER DEFAULT 1,
|
||||
notify_packing_tagged INTEGER DEFAULT 1,
|
||||
notify_webhook INTEGER DEFAULT 1,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and run only migration 69 (index 68) from the migrations array.
|
||||
* We do this by importing migrations and calling the last one directly.
|
||||
*/
|
||||
function runMigration69(db: ReturnType<typeof Database>): void {
|
||||
// Migration 69 logic extracted inline for isolation
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, event_type, channel)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||
`);
|
||||
|
||||
const oldPrefs = db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>;
|
||||
const eventCols: Record<string, string> = {
|
||||
trip_invite: 'notify_trip_invite',
|
||||
booking_change: 'notify_booking_change',
|
||||
trip_reminder: 'notify_trip_reminder',
|
||||
vacay_invite: 'notify_vacay_invite',
|
||||
photos_shared: 'notify_photos_shared',
|
||||
collab_message: 'notify_collab_message',
|
||||
packing_tagged: 'notify_packing_tagged',
|
||||
};
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
|
||||
for (const [userId, eventType, channel, enabled] of rows) {
|
||||
insert.run(userId, eventType, channel, enabled);
|
||||
}
|
||||
});
|
||||
|
||||
for (const row of oldPrefs) {
|
||||
const userId = row.user_id as number;
|
||||
const webhookEnabled = (row.notify_webhook as number) ?? 0;
|
||||
const rows: Array<[number, string, string, number]> = [];
|
||||
for (const [eventType, col] of Object.entries(eventCols)) {
|
||||
const emailEnabled = (row[col] as number) ?? 1;
|
||||
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
|
||||
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
|
||||
}
|
||||
if (rows.length > 0) insertMany(rows);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO app_settings (key, value)
|
||||
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
|
||||
`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Migration 69 tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Migration 69 — normalized notification_channel_preferences', () => {
|
||||
it('MIGR-001 — notification_channel_preferences table exists after migration', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
runMigration69(db);
|
||||
|
||||
const table = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'`
|
||||
).get();
|
||||
expect(table).toBeDefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-002 — old notification_preferences rows with disabled events migrated as enabled=0', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Create a user
|
||||
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('testuser', 'hash', 'user')).lastInsertRowid as number;
|
||||
|
||||
// Simulate user who has disabled trip_invite and booking_change email
|
||||
db.prepare(`
|
||||
INSERT INTO notification_preferences
|
||||
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
|
||||
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
|
||||
VALUES (?, 0, 0, 1, 1, 1, 1, 1, 1)
|
||||
`).run(userId);
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const tripInviteEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'trip_invite', 'email') as { enabled: number } | undefined;
|
||||
const bookingEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'booking_change', 'email') as { enabled: number } | undefined;
|
||||
const reminderEmail = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined;
|
||||
|
||||
// Disabled events should have enabled=0 rows
|
||||
expect(tripInviteEmail).toBeDefined();
|
||||
expect(tripInviteEmail!.enabled).toBe(0);
|
||||
expect(bookingEmail).toBeDefined();
|
||||
expect(bookingEmail!.enabled).toBe(0);
|
||||
// Enabled events should have no row (no-row = enabled)
|
||||
expect(reminderEmail).toBeUndefined();
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-003 — old notify_webhook=0 creates disabled webhook rows for all 7 events', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('webhookuser', 'hash', 'user')).lastInsertRowid as number;
|
||||
|
||||
// User has all email enabled but webhook disabled
|
||||
db.prepare(`
|
||||
INSERT INTO notification_preferences
|
||||
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
|
||||
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
|
||||
VALUES (?, 1, 1, 1, 1, 1, 1, 1, 0)
|
||||
`).run(userId);
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const allEvents = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
|
||||
for (const eventType of allEvents) {
|
||||
const row = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, 'webhook') as { enabled: number } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.enabled).toBe(0);
|
||||
|
||||
// Email rows should NOT exist (all email was enabled → no row needed)
|
||||
const emailRow = db.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(userId, eventType, 'email');
|
||||
expect(emailRow).toBeUndefined();
|
||||
}
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-004 — notification_channels key is created in app_settings from notification_channel value', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Simulate existing single-channel setting
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
|
||||
expect(plural).toBeDefined();
|
||||
expect(plural!.value).toBe('email');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('MIGR-004b — notification_channels is not duplicated if already exists', () => {
|
||||
const db = setupPreMigration69Db();
|
||||
|
||||
// Both keys already set (e.g. partial migration or manual edit)
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
|
||||
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channels', 'email,webhook');
|
||||
|
||||
runMigration69(db);
|
||||
|
||||
// The existing notification_channels value should be preserved (INSERT OR IGNORE)
|
||||
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
|
||||
expect(plural!.value).toBe('email,webhook');
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Unit tests for notificationPreferencesService.
|
||||
* Covers NPREF-001 to NPREF-021.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: (v: string | null) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
|
||||
import {
|
||||
isEnabledForEvent,
|
||||
getPreferencesMatrix,
|
||||
setPreferences,
|
||||
setAdminPreferences,
|
||||
getAdminGlobalPref,
|
||||
getActiveChannels,
|
||||
getAvailableChannels,
|
||||
} from '../../../src/services/notificationPreferencesService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// isEnabledForEvent
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isEnabledForEvent', () => {
|
||||
it('NPREF-001 — returns true when no row exists (default enabled)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-002 — returns true when row exists with enabled=1', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)'
|
||||
).run(user.id, 'trip_invite', 'email');
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-003 — returns false when row exists with enabled=0', () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getPreferencesMatrix
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getPreferencesMatrix', () => {
|
||||
it('NPREF-004 — regular user does not see version_available in event_types', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { event_types } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(event_types).not.toContain('version_available');
|
||||
expect(event_types.length).toBe(7);
|
||||
});
|
||||
|
||||
it('NPREF-005 — user scope excludes version_available for everyone including admins', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user');
|
||||
expect(event_types).not.toContain('version_available');
|
||||
expect(event_types.length).toBe(7);
|
||||
});
|
||||
|
||||
it('NPREF-005b — admin scope returns only version_available', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||
expect(event_types).toContain('version_available');
|
||||
expect(event_types.length).toBe(1);
|
||||
});
|
||||
|
||||
it('NPREF-006 — returns default true for all preferences when no stored prefs', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { preferences } = getPreferencesMatrix(user.id, 'user');
|
||||
for (const [, channels] of Object.entries(preferences)) {
|
||||
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
|
||||
expect(enabled).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('NPREF-007 — reflects stored disabled preferences in the matrix', () => {
|
||||
const { user } = createUser(testDb);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
disableNotificationPref(testDb, user.id, 'collab_message', 'webhook');
|
||||
const { preferences } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(preferences['trip_invite']!['email']).toBe(false);
|
||||
expect(preferences['collab_message']!['webhook']).toBe(false);
|
||||
// Others unaffected
|
||||
expect(preferences['trip_invite']!['webhook']).toBe(true);
|
||||
expect(preferences['booking_change']!['email']).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-008 — available_channels.inapp is always true', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.inapp).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-009 — available_channels.email is true when email is in notification_channels', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'email');
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.email).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-010 — available_channels.email is false when email is not in notification_channels', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// No notification_channels set → defaults to none
|
||||
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||
expect(available_channels.email).toBe(false);
|
||||
});
|
||||
|
||||
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']);
|
||||
// All events now support all three channels
|
||||
expect(implemented_combos['trip_invite']).toContain('inapp');
|
||||
expect(implemented_combos['trip_invite']).toContain('email');
|
||||
expect(implemented_combos['trip_invite']).toContain('webhook');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// setPreferences
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('setPreferences', () => {
|
||||
it('NPREF-012 — disabling a preference inserts a row with enabled=0', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setPreferences(user.id, { trip_invite: { email: false } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.enabled).toBe(0);
|
||||
});
|
||||
|
||||
it('NPREF-013 — re-enabling a preference removes the disabled row', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// First disable
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
// Then re-enable
|
||||
setPreferences(user.id, { trip_invite: { email: true } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email');
|
||||
// Row should be deleted — default is enabled
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-014 — bulk update handles multiple event+channel combos', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setPreferences(user.id, {
|
||||
trip_invite: { email: false, webhook: false },
|
||||
booking_change: { email: false },
|
||||
trip_reminder: { webhook: true },
|
||||
});
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||
expect(isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
|
||||
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
|
||||
// trip_reminder webhook was set to true → no row, default enabled
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_reminder', 'webhook');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getActiveChannels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getActiveChannels', () => {
|
||||
it('NPREF-015 — returns [] when notification_channels is none', () => {
|
||||
setAppSetting(testDb, 'notification_channels', 'none');
|
||||
expect(getActiveChannels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('NPREF-016 — returns [email] when notification_channels is email', () => {
|
||||
setAppSetting(testDb, 'notification_channels', 'email');
|
||||
expect(getActiveChannels()).toEqual(['email']);
|
||||
});
|
||||
|
||||
it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', () => {
|
||||
setAppSetting(testDb, 'notification_channels', 'email,webhook');
|
||||
expect(getActiveChannels()).toEqual(['email', 'webhook']);
|
||||
});
|
||||
|
||||
it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', () => {
|
||||
// Only set the singular key
|
||||
setAppSetting(testDb, 'notification_channel', 'webhook');
|
||||
// No notification_channels key
|
||||
expect(getActiveChannels()).toEqual(['webhook']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getAvailableChannels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAvailableChannels', () => {
|
||||
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => {
|
||||
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.email).toBe(true);
|
||||
expect(channels.inapp).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-020 — webhook available when admin has enabled the webhook channel', () => {
|
||||
setNotificationChannels(testDb, 'webhook');
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.webhook).toBe(true);
|
||||
});
|
||||
|
||||
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => {
|
||||
const original = process.env.SMTP_HOST;
|
||||
process.env.SMTP_HOST = 'env-mail.example.com';
|
||||
try {
|
||||
const channels = getAvailableChannels();
|
||||
expect(channels.email).toBe(true);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.SMTP_HOST;
|
||||
else process.env.SMTP_HOST = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// setAdminPreferences
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('setAdminPreferences', () => {
|
||||
it('NPREF-022 — disabling email for version_available stores global pref in app_settings', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
setAdminPreferences(user.id, { version_available: { email: false } });
|
||||
expect(getAdminGlobalPref('version_available', 'email')).toBe(false);
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
||||
expect(row?.value).toBe('0');
|
||||
});
|
||||
|
||||
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
setAdminPreferences(user.id, { version_available: { inapp: false } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.enabled).toBe(0);
|
||||
// Global app_settings should NOT have an inapp key
|
||||
const globalRow = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_inapp');
|
||||
expect(globalRow).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-024 — re-enabling inapp removes the disabled per-user row', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
// First disable
|
||||
disableNotificationPref(testDb, user.id, 'version_available', 'inapp');
|
||||
// Then re-enable via setAdminPreferences
|
||||
setAdminPreferences(user.id, { version_available: { inapp: true } });
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'version_available', 'inapp');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NPREF-025 — enabling email stores global pref as "1" in app_settings', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
// First disable, then re-enable
|
||||
setAdminPreferences(user.id, { version_available: { email: false } });
|
||||
setAdminPreferences(user.id, { version_available: { email: true } });
|
||||
expect(getAdminGlobalPref('version_available', 'email')).toBe(true);
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
||||
expect(row?.value).toBe('1');
|
||||
});
|
||||
});
|
||||
459
server/tests/unit/services/notificationService.test.ts
Normal file
459
server/tests/unit/services/notificationService.test.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Unit tests for the unified notificationService.send().
|
||||
* Covers NSVC-001 to NSVC-014.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: (v: string | null) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
|
||||
const { sendMailMock, fetchMock, broadcastMock } = vi.hoisted(() => ({
|
||||
sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }),
|
||||
fetchMock: vi.fn(),
|
||||
broadcastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: {
|
||||
createTransport: vi.fn(() => ({
|
||||
sendMail: sendMailMock,
|
||||
verify: vi.fn().mockResolvedValue(true),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node-fetch', () => ({ default: fetchMock }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
|
||||
createPinnedAgent: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
|
||||
import { send } from '../../../src/services/notificationService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function setSmtp(): void {
|
||||
setAppSetting(testDb, 'smtp_host', 'mail.test.com');
|
||||
setAppSetting(testDb, 'smtp_port', '587');
|
||||
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
|
||||
}
|
||||
|
||||
function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): void {
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)").run(userId, url);
|
||||
}
|
||||
|
||||
function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void {
|
||||
setAppSetting(testDb, 'admin_webhook_url', url);
|
||||
}
|
||||
|
||||
function getInAppNotifications(recipientId: number) {
|
||||
return testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ? ORDER BY id').all(recipientId) as Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
scope: string;
|
||||
navigate_target: string | null;
|
||||
navigate_text_key: string | null;
|
||||
title_key: string;
|
||||
text_key: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function countAllNotifications(): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||
}
|
||||
|
||||
// ── Setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
sendMailMock.mockClear();
|
||||
fetchMock.mockClear();
|
||||
broadcastMock.mockClear();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Multi-channel dispatch
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — multi-channel dispatch', () => {
|
||||
it('NSVC-001 — dispatches to all 3 channels (inapp, email, webhook) when all are active', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-003 — sends only email when only email channel is active', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Per-user preference filtering
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — per-user preference filtering', () => {
|
||||
it('NSVC-004 — skips email for a user who disabled trip_invite on email channel', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
// in-app still fires
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('NSVC-005 — skips in-app for a user who disabled the event on inapp channel', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } });
|
||||
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
expect(countAllNotifications()).toBe(0);
|
||||
});
|
||||
|
||||
it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Recipient resolution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — recipient resolution', () => {
|
||||
it('NSVC-007 — trip scope sends to owner + members, excludes actorId', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member1 } = createUser(testDb);
|
||||
const { user: member2 } = createUser(testDb);
|
||||
const { user: actor } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id);
|
||||
|
||||
await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
// Owner, member1, member2 get it; actor is excluded
|
||||
expect(countAllNotifications()).toBe(3);
|
||||
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||
expect(recipients).toContain(owner.id);
|
||||
expect(recipients).toContain(member1.id);
|
||||
expect(recipients).toContain(member2.id);
|
||||
expect(recipients).not.toContain(actor.id);
|
||||
});
|
||||
|
||||
it('NSVC-008 — user scope sends to exactly one user', async () => {
|
||||
const { user: target } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } });
|
||||
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number };
|
||||
expect(notif.recipient_id).toBe(target.id);
|
||||
});
|
||||
|
||||
it('NSVC-009 — admin scope sends to all admins (not regular users)', async () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
createUser(testDb); // regular user — should NOT receive
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
|
||||
|
||||
expect(countAllNotifications()).toBe(2);
|
||||
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||
expect(recipients).toContain(admin1.id);
|
||||
expect(recipients).toContain(admin2.id);
|
||||
});
|
||||
|
||||
it('NSVC-010 — admin scope fires admin webhook URL when set', async () => {
|
||||
createAdmin(testDb);
|
||||
setAdminWebhookUrl();
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
|
||||
|
||||
// Wait for fire-and-forget admin webhook
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = fetchMock.mock.calls[0][0];
|
||||
expect(callUrl).toBe('https://hooks.test.com/admin-webhook');
|
||||
});
|
||||
|
||||
it('NSVC-011 — does nothing when there are no recipients', async () => {
|
||||
// Trip with no members, sending as the trip owner (actor excluded from trip scope)
|
||||
const { user: owner } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
expect(countAllNotifications()).toBe(0);
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-app notification content
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — in-app notification content', () => {
|
||||
it('NSVC-012 — creates navigate in-app notification with correct title/text/navigate keys', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } });
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].type).toBe('navigate');
|
||||
expect(notifs[0].title_key).toBe('notif.trip_invite.title');
|
||||
expect(notifs[0].text_key).toBe('notif.trip_invite.text');
|
||||
expect(notifs[0].navigate_text_key).toBe('notif.action.view_trip');
|
||||
expect(notifs[0].navigate_target).toBe('/trips/42');
|
||||
});
|
||||
|
||||
it('NSVC-013 — creates simple in-app notification when no navigate target is available', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
// vacay_invite without planId → no navigate target → simple type
|
||||
await send({ event: 'vacay_invite', actorId: null, scope: 'user', targetId: user.id, params: { actor: 'Alice' } });
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].type).toBe('simple');
|
||||
expect(notifs[0].navigate_target).toBeNull();
|
||||
});
|
||||
|
||||
it('NSVC-014 — navigate_target uses /admin for version_available event', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
|
||||
|
||||
const notifs = getInAppNotifications(admin.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].navigate_target).toBe('/admin');
|
||||
expect(notifs[0].title_key).toBe('notif.version_available.title');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Email/webhook link generation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — email/webhook links', () => {
|
||||
it('NSVC-015 — email subject and body are localized per recipient language', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
// Set user language to French
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
||||
// French title for trip_invite should contain "Invitation"
|
||||
expect(mailArgs.subject).toContain('Invitation');
|
||||
});
|
||||
|
||||
it('NSVC-016 — webhook payload includes link field when navigate target is available', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook');
|
||||
setNotificationChannels(testDb, 'webhook');
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
// Generic webhook — link should contain /trips/55
|
||||
expect(body.link).toContain('/trips/55');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Boolean in-app type
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — boolean in-app type', () => {
|
||||
it('NSVC-017 — creates boolean in-app notification with callbacks when inApp.type override is boolean', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
await send({
|
||||
event: 'trip_invite',
|
||||
actorId: null,
|
||||
scope: 'user',
|
||||
targetId: user.id,
|
||||
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' },
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveTextKey: 'notif.action.accept',
|
||||
negativeTextKey: 'notif.action.decline',
|
||||
positiveCallback: { action: 'test_approve', payload: { tripId: 1 } },
|
||||
negativeCallback: { action: 'test_deny', payload: { tripId: 1 } },
|
||||
},
|
||||
});
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
const row = notifs[0] as any;
|
||||
expect(row.type).toBe('boolean');
|
||||
expect(row.positive_callback).toContain('test_approve');
|
||||
expect(row.negative_callback).toContain('test_deny');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Channel failure resilience
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send() — channel failure resilience', () => {
|
||||
it('NSVC-018 — email failure does not prevent in-app or webhook delivery', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
|
||||
// Make email throw
|
||||
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
// In-app and webhook still fire despite email failure
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
|
||||
// Make webhook throw
|
||||
fetchMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
// In-app and email still fire despite webhook failure
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
|
||||
@@ -18,7 +18,15 @@ vi.mock('../../../src/services/auditLog', () => ({
|
||||
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
|
||||
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||
|
||||
import { getEventText, buildEmailHtml, buildWebhookBody } from '../../../src/services/notifications';
|
||||
// ssrfGuard is mocked per-test in the SSRF describe block; default passes all
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
|
||||
createPinnedAgent: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications';
|
||||
import { checkSsrf } from '../../../src/utils/ssrfGuard';
|
||||
import { logError } from '../../../src/services/auditLog';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -193,3 +201,119 @@ describe('buildEmailHtml', () => {
|
||||
expect(unknown).toContain('notifications enabled in TREK');
|
||||
});
|
||||
});
|
||||
|
||||
// ── SEC: XSS escaping in buildEmailHtml ──────────────────────────────────────
|
||||
|
||||
describe('buildEmailHtml XSS prevention (SEC-016)', () => {
|
||||
it('escapes HTML special characters in subject', () => {
|
||||
const html = buildEmailHtml('<script>alert(1)</script>', 'Body', 'en');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes HTML special characters in body', () => {
|
||||
const html = buildEmailHtml('Subject', '<img src=x onerror=alert(1)>', 'en');
|
||||
expect(html).toContain('<img');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
});
|
||||
|
||||
it('escapes double quotes in subject to prevent attribute injection', () => {
|
||||
const html = buildEmailHtml('He said "hello"', 'Body', 'en');
|
||||
expect(html).toContain('"');
|
||||
expect(html).not.toContain('"hello"');
|
||||
});
|
||||
|
||||
it('escapes ampersands in body', () => {
|
||||
const html = buildEmailHtml('Subject', 'a & b', 'en');
|
||||
expect(html).toContain('&');
|
||||
expect(html).not.toMatch(/>[^<]*a & b[^<]*</);
|
||||
});
|
||||
|
||||
it('escapes user-controlled actor and preview in collab_message body', () => {
|
||||
const { body } = getEventText('en', 'collab_message', {
|
||||
trip: 'MyTrip',
|
||||
actor: '<evil>',
|
||||
preview: '<script>xss()</script>',
|
||||
});
|
||||
const html = buildEmailHtml('Subject', body, 'en');
|
||||
expect(html).not.toContain('<evil>');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<evil>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
// ── SEC: SSRF protection in sendWebhook ──────────────────────────────────────
|
||||
|
||||
describe('sendWebhook SSRF protection (SEC-017)', () => {
|
||||
const payload = { event: 'test', title: 'T', body: 'B' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(logError).mockClear();
|
||||
});
|
||||
|
||||
it('allows a public URL and calls fetch', async () => {
|
||||
const mockFetch = (await import('node-fetch')).default as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
||||
|
||||
const result = await sendWebhook('https://example.com/hook', payload);
|
||||
expect(result).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks loopback address and returns false', async () => {
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
|
||||
error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
});
|
||||
|
||||
const result = await sendWebhook('http://localhost/secret', payload);
|
||||
expect(result).toBe(false);
|
||||
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
||||
});
|
||||
|
||||
it('blocks cloud metadata endpoint (169.254.169.254) and returns false', async () => {
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '169.254.169.254',
|
||||
error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
});
|
||||
|
||||
const result = await sendWebhook('http://169.254.169.254/latest/meta-data', payload);
|
||||
expect(result).toBe(false);
|
||||
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
||||
});
|
||||
|
||||
it('blocks private network addresses and returns false', async () => {
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
|
||||
error: 'Requests to private/internal network addresses are not allowed',
|
||||
});
|
||||
|
||||
const result = await sendWebhook('http://192.168.1.1/hook', payload);
|
||||
expect(result).toBe(false);
|
||||
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
||||
});
|
||||
|
||||
it('blocks non-HTTP protocols', async () => {
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: false,
|
||||
error: 'Only HTTP and HTTPS URLs are allowed',
|
||||
});
|
||||
|
||||
const result = await sendWebhook('file:///etc/passwd', payload);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call fetch when SSRF check blocks the URL', async () => {
|
||||
const mockFetch = (await import('node-fetch')).default as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockClear();
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
|
||||
error: 'blocked',
|
||||
});
|
||||
|
||||
await sendWebhook('http://localhost/secret', payload);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
173
server/tests/unit/services/versionNotification.test.ts
Normal file
173
server/tests/unit/services/versionNotification.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Unit tests for checkAndNotifyVersion() in adminService.
|
||||
* Covers VNOTIF-001 to VNOTIF-007.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
// Mock MCP to avoid session side-effects
|
||||
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createAdmin } from '../../helpers/factories';
|
||||
import { checkAndNotifyVersion } from '../../../src/services/adminService';
|
||||
|
||||
// Helper: mock the GitHub releases/latest endpoint
|
||||
function mockGitHubLatest(tagName: string, ok = true): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: async () => ({ tag_name: tagName, html_url: `https://github.com/mauriceboe/TREK/releases/tag/${tagName}` }),
|
||||
}));
|
||||
}
|
||||
|
||||
function mockGitHubFetchFailure(): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
}
|
||||
|
||||
function getLastNotifiedVersion(): string | undefined {
|
||||
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||
}
|
||||
|
||||
function getNotificationCount(): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// checkAndNotifyVersion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('checkAndNotifyVersion', () => {
|
||||
it('VNOTIF-001 — does nothing when no update is available', async () => {
|
||||
createAdmin(testDb);
|
||||
// GitHub reports same version as package.json (or older) → update_available: false
|
||||
const { version } = require('../../../package.json');
|
||||
mockGitHubLatest(`v${version}`);
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getNotificationCount()).toBe(0);
|
||||
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => {
|
||||
const { user: admin1 } = createAdmin(testDb);
|
||||
const { user: admin2 } = createAdmin(testDb);
|
||||
mockGitHubLatest('v99.0.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{ recipient_id: number; type: string; scope: string }>;
|
||||
expect(notifications.length).toBe(2);
|
||||
const recipientIds = notifications.map(n => n.recipient_id);
|
||||
expect(recipientIds).toContain(admin1.id);
|
||||
expect(recipientIds).toContain(admin2.id);
|
||||
expect(notifications[0].type).toBe('navigate');
|
||||
expect(notifications[0].scope).toBe('admin');
|
||||
});
|
||||
|
||||
it('VNOTIF-003 — sets last_notified_version in app_settings after notifying', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.1.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getLastNotifiedVersion()).toBe('99.1.0');
|
||||
});
|
||||
|
||||
it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.2.0');
|
||||
|
||||
// First call notifies
|
||||
await checkAndNotifyVersion();
|
||||
const countAfterFirst = getNotificationCount();
|
||||
expect(countAfterFirst).toBe(1);
|
||||
|
||||
// Second call with same version — should not create another
|
||||
await checkAndNotifyVersion();
|
||||
expect(getNotificationCount()).toBe(countAfterFirst);
|
||||
});
|
||||
|
||||
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
|
||||
createAdmin(testDb);
|
||||
// Simulate having been notified about an older version
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', '98.0.0');
|
||||
mockGitHubLatest('v99.3.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getNotificationCount()).toBe(1);
|
||||
expect(getLastNotifiedVersion()).toBe('99.3.0');
|
||||
});
|
||||
|
||||
it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubLatest('v99.4.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as {
|
||||
type: string;
|
||||
scope: string;
|
||||
navigate_target: string;
|
||||
title_key: string;
|
||||
text_key: string;
|
||||
navigate_text_key: string;
|
||||
};
|
||||
expect(notif.type).toBe('navigate');
|
||||
expect(notif.scope).toBe('admin');
|
||||
expect(notif.navigate_target).toBe('/admin');
|
||||
expect(notif.title_key).toBe('notif.version_available.title');
|
||||
expect(notif.text_key).toBe('notif.version_available.text');
|
||||
expect(notif.navigate_text_key).toBe('notif.action.view_admin');
|
||||
});
|
||||
|
||||
it('VNOTIF-007 — silently handles GitHub API fetch failure (no crash, no notification)', async () => {
|
||||
createAdmin(testDb);
|
||||
mockGitHubFetchFailure();
|
||||
|
||||
// Should not throw
|
||||
await expect(checkAndNotifyVersion()).resolves.toBeUndefined();
|
||||
expect(getNotificationCount()).toBe(0);
|
||||
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user