Merge branch 'dev' into test

This commit is contained in:
Marek Maslowski
2026-04-05 10:26:09 +02:00
63 changed files with 24436 additions and 18674 deletions
+26 -28
View File
@@ -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 });
}
+33
View File
@@ -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) => {
+4 -4
View File
@@ -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(() => {});
});
});
+14 -10
View File
@@ -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 ──────────────────────────────────────────────────────
+3 -6
View File
@@ -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(() => {});
});
}
});
+6 -6
View File
@@ -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(() => {});
});
});
+1
View File
@@ -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 });
});
+2 -2
View File
@@ -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 });