Merge branch 'dev' into test
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user