diff --git a/.gitignore b/.gitignore index fbf5e80..f58a53e 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ coverage *.tsbuildinfo *.tgz -.scannerwork \ No newline at end of file +.scannerwork +test-data \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index d77755a..621201e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -43,7 +43,8 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps } if (!isAuthenticated) { - return + const redirectParam = encodeURIComponent(location.pathname + location.search) + return } if ( diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 179021c..1e283f3 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -27,7 +27,8 @@ apiClient.interceptors.response.use( (error) => { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) { - window.location.href = '/login' + const currentPath = window.location.pathname + window.location.search + window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) } } if ( @@ -197,6 +198,8 @@ export const adminApi = { rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), sendTestNotification: (data: Record) => apiClient.post('/admin/dev/test-notification', data).then(r => r.data), + getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data), + updateNotificationPreferences: (prefs: Record>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data), } export const addonsApi = { @@ -326,9 +329,9 @@ export const shareApi = { export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), - updatePreferences: (prefs: Record) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), + updatePreferences: (prefs: Record>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data), - testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data), + testWebhook: (url: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data), } export const inAppNotificationsApi = { diff --git a/client/src/components/Admin/DevNotificationsPanel.tsx b/client/src/components/Admin/DevNotificationsPanel.tsx index 31572ee..8221b51 100644 --- a/client/src/components/Admin/DevNotificationsPanel.tsx +++ b/client/src/components/Admin/DevNotificationsPanel.tsx @@ -2,7 +2,11 @@ import React, { useState, useEffect } from 'react' import { adminApi, tripsApi } from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useToast } from '../shared/Toast' -import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react' +import { + Bell, Zap, ArrowRight, CheckCircle, Navigation, User, + Calendar, Clock, Image, MessageSquare, Tag, UserPlus, + Download, MapPin, +} from 'lucide-react' interface Trip { id: number @@ -37,7 +41,7 @@ export default function DevNotificationsPanel(): React.ReactElement { }).catch(() => {}) }, []) - const send = async (label: string, payload: Record) => { + const fire = async (label: string, payload: Record) => { setSending(label) try { await adminApi.sendTestNotification(payload) @@ -49,74 +53,69 @@ export default function DevNotificationsPanel(): React.ReactElement { } } - const buttons = [ - { - label: 'Simple → Me', - icon: Bell, - color: '#6366f1', - payload: { - type: 'simple', - scope: 'user', - target: user?.id, - title_key: 'notifications.test.title', - title_params: { actor: user?.username || 'Admin' }, - text_key: 'notifications.test.text', - text_params: {}, - }, - }, - { - label: 'Boolean → Me', - icon: CheckCircle, - color: '#10b981', - payload: { - type: 'boolean', - scope: 'user', - target: user?.id, - title_key: 'notifications.test.booleanTitle', - title_params: { actor: user?.username || 'Admin' }, - text_key: 'notifications.test.booleanText', - text_params: {}, - positive_text_key: 'notifications.test.accept', - negative_text_key: 'notifications.test.decline', - positive_callback: { action: 'test_approve', payload: {} }, - negative_callback: { action: 'test_deny', payload: {} }, - }, - }, - { - label: 'Navigate → Me', - icon: Navigation, - color: '#f59e0b', - payload: { - type: 'navigate', - scope: 'user', - target: user?.id, - title_key: 'notifications.test.navigateTitle', - title_params: {}, - text_key: 'notifications.test.navigateText', - text_params: {}, - navigate_text_key: 'notifications.test.goThere', - navigate_target: '/dashboard', - }, - }, - { - label: 'Simple → Admins', - icon: Zap, - color: '#ef4444', - payload: { - type: 'simple', - scope: 'admin', - target: 0, - title_key: 'notifications.test.adminTitle', - title_params: {}, - text_key: 'notifications.test.adminText', - text_params: { actor: user?.username || 'Admin' }, - }, - }, - ] + const selectedTrip = trips.find(t => t.id === selectedTripId) + const selectedUser = users.find(u => u.id === selectedUserId) + const username = user?.username || 'Admin' + const tripTitle = selectedTrip?.title || 'Test Trip' + + // ── Helpers ────────────────────────────────────────────────────────────── + + const Btn = ({ + id, label, sub, icon: Icon, color, onClick, + }: { + id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void + }) => ( + + ) + + const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ) + + const TripSelector = () => ( + + ) + + const UserSelector = () => ( + + ) return ( -
-
+
+
DEV ONLY
@@ -125,219 +124,162 @@ export default function DevNotificationsPanel(): React.ReactElement {
-

- Send test notifications to yourself, all admins, or trip members. These use test i18n keys. -

- - {/* Quick-fire buttons */} + {/* ── Type Testing ─────────────────────────────────────────────────── */}
-

Quick Send

+ Type Testing +

+ Test how each in-app notification type renders, sent to yourself. +

- {buttons.map(btn => { - const Icon = btn.icon - return ( - - ) - })} + fire('simple-me', { + event: 'test_simple', + scope: 'user', + targetId: user?.id, + params: {}, + })} + /> + fire('boolean-me', { + event: 'test_boolean', + scope: 'user', + targetId: user?.id, + params: {}, + inApp: { + type: 'boolean', + positiveCallback: { action: 'test_approve', payload: {} }, + negativeCallback: { action: 'test_deny', payload: {} }, + }, + })} + /> + fire('navigate-me', { + event: 'test_navigate', + scope: 'user', + targetId: user?.id, + params: {}, + })} + /> + fire('simple-admins', { + event: 'test_simple', + scope: 'admin', + targetId: 0, + params: {}, + })} + />
- {/* Trip-scoped notifications */} + {/* ── Trip-Scoped Events ───────────────────────────────────────────── */} {trips.length > 0 && (
-

Trip-Scoped

-
- -
+ Trip-Scoped Events +

+ Fires each trip event to all members of the selected trip (excluding yourself). +

+
- - + /> + selectedTripId && fire('photos_shared', { + event: 'photos_shared', + scope: 'trip', + targetId: selectedTripId, + params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) }, + })} + /> + selectedTripId && fire('collab_message', { + event: 'collab_message', + scope: 'trip', + targetId: selectedTripId, + params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) }, + })} + /> + selectedTripId && fire('packing_tagged', { + event: 'packing_tagged', + scope: 'trip', + targetId: selectedTripId, + params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) }, + })} + />
)} - {/* User-scoped notifications */} + {/* ── User-Scoped Events ───────────────────────────────────────────── */} {users.length > 0 && (
-

User-Scoped

-
- -
+ User-Scoped Events +

+ Fires each user event to the selected recipient. +

+
- - - + />
)} + + {/* ── Admin-Scoped Events ──────────────────────────────────────────── */} +
+ Admin-Scoped Events +

+ Fires to all admin users. +

+
+ fire('version_available', { + event: 'version_available', + scope: 'admin', + targetId: 0, + params: { version: '9.9.9-test' }, + })} + /> +
+
) } diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 8519cc1..bb65525 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -513,8 +513,8 @@ const ar: Record = { 'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'القوائم', + 'admin.addons.catalog.packing.description': 'قوائم التعبئة والمهام لرحلاتك', 'admin.addons.catalog.budget.name': 'الميزانية', 'admin.addons.catalog.budget.description': 'تتبع النفقات وخطط ميزانية الرحلة', 'admin.addons.catalog.documents.name': 'المستندات', @@ -743,8 +743,8 @@ const ar: Record = { 'trip.tabs.reservationsShort': 'حجز', 'trip.tabs.packing': 'قائمة التجهيز', 'trip.tabs.packingShort': 'تجهيز', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'القوائم', + 'trip.tabs.listsShort': 'القوائم', 'trip.tabs.budget': 'الميزانية', 'trip.tabs.files': 'الملفات', 'trip.loading': 'جارٍ تحميل الرحلة...', @@ -940,11 +940,11 @@ const ar: Record = { 'reservations.linkAssignment': 'ربط بخطة اليوم', 'reservations.pickAssignment': 'اختر عنصرًا من خطتك...', 'reservations.noAssignment': 'بلا ربط', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'السعر', + 'reservations.budgetCategory': 'فئة الميزانية', + 'reservations.budgetCategoryPlaceholder': 'مثال: المواصلات، الإقامة', + 'reservations.budgetCategoryAuto': 'تلقائي (حسب نوع الحجز)', + 'reservations.budgetHint': 'سيتم إنشاء إدخال في الميزانية تلقائيًا عند الحفظ.', 'reservations.departureDate': 'المغادرة', 'reservations.arrivalDate': 'الوصول', 'reservations.departureTime': 'وقت المغادرة', @@ -1576,38 +1576,105 @@ const ar: Record = { 'notifications.test.tripText': 'إشعار تجريبي للرحلة "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': 'قائمة الأمتعة', + 'todo.subtab.todo': 'المهام', + 'todo.completed': 'مكتمل', + 'todo.filter.all': 'الكل', + 'todo.filter.open': 'مفتوح', + 'todo.filter.done': 'منجز', + 'todo.uncategorized': 'بدون تصنيف', + 'todo.namePlaceholder': 'اسم المهمة', + 'todo.descriptionPlaceholder': 'وصف (اختياري)', + 'todo.unassigned': 'غير مُسنَد', + 'todo.noCategory': 'بدون فئة', + 'todo.hasDescription': 'له وصف', + 'todo.addItem': 'إضافة مهمة جديدة...', + 'todo.newCategory': 'اسم الفئة', + 'todo.addCategory': 'إضافة فئة', + 'todo.newItem': 'مهمة جديدة', + 'todo.empty': 'لا توجد مهام بعد. أضف مهمة للبدء!', + 'todo.filter.my': 'مهامي', + 'todo.filter.overdue': 'متأخرة', + 'todo.sidebar.tasks': 'المهام', + 'todo.sidebar.categories': 'الفئات', + 'todo.detail.title': 'مهمة', + 'todo.detail.description': 'وصف', + 'todo.detail.category': 'فئة', + 'todo.detail.dueDate': 'تاريخ الاستحقاق', + 'todo.detail.assignedTo': 'مسند إلى', + 'todo.detail.delete': 'حذف', + 'todo.detail.save': 'حفظ التغييرات', + 'todo.detail.create': 'إنشاء مهمة', + 'todo.detail.priority': 'الأولوية', + 'todo.detail.noPriority': 'لا شيء', + 'todo.sortByPrio': 'الأولوية', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'إصدار جديد متاح', + 'settings.notificationPreferences.noChannels': 'لم يتم تكوين قنوات إشعارات. اطلب من المسؤول إعداد إشعارات البريد الإلكتروني أو webhook.', + 'settings.webhookUrl.label': 'رابط Webhook', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'أدخل رابط Webhook الخاص بـ Discord أو Slack أو المخصص لتلقي الإشعارات.', + 'settings.webhookUrl.save': 'حفظ', + 'settings.webhookUrl.saved': 'تم حفظ رابط Webhook', + 'settings.webhookUrl.test': 'اختبار', + 'settings.webhookUrl.testSuccess': 'تم إرسال Webhook الاختباري بنجاح', + 'settings.webhookUrl.testFailed': 'فشل إرسال Webhook الاختباري', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'الإشعارات داخل التطبيق نشطة دائمًا ولا يمكن تعطيلها بشكل عام.', + 'admin.notifications.adminWebhookPanel.title': 'Webhook المسؤول', + 'admin.notifications.adminWebhookPanel.hint': 'يُستخدم هذا الـ Webhook حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن Webhooks المستخدمين ويُرسل تلقائيًا عند تعيين رابط URL.', + 'admin.notifications.adminWebhookPanel.saved': 'تم حفظ رابط Webhook المسؤول', + 'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح', + 'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL', + 'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.', + 'admin.tabs.notifications': 'الإشعارات', + 'admin.tabs.notificationChannels': 'قنوات الإشعارات', + 'admin.tabs.adminNotifications': 'إشعارات المسؤول', + 'notifications.versionAvailable.title': 'تحديث متاح', + 'notifications.versionAvailable.text': 'TREK {version} متاح الآن.', + 'notifications.versionAvailable.button': 'عرض التفاصيل', + 'notif.test.title': '[اختبار] إشعار', + 'notif.test.simple.text': 'هذا إشعار اختبار بسيط.', + 'notif.test.boolean.text': 'هل تقبل هذا الإشعار الاختباري؟', + 'notif.test.navigate.text': 'انقر أدناه للانتقال إلى لوحة التحكم.', + + // Notifications + 'notif.trip_invite.title': 'دعوة للرحلة', + 'notif.trip_invite.text': '{actor} دعاك إلى {trip}', + 'notif.booking_change.title': 'تم تحديث الحجز', + 'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}', + 'notif.trip_reminder.title': 'تذكير بالرحلة', + 'notif.trip_reminder.text': 'رحلتك {trip} تقترب!', + 'notif.vacay_invite.title': 'دعوة دمج الإجازة', + 'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة', + 'notif.photos_shared.title': 'تمت مشاركة الصور', + 'notif.photos_shared.text': '{actor} شارك {count} صورة في {trip}', + 'notif.collab_message.title': 'رسالة جديدة', + 'notif.collab_message.text': '{actor} أرسل رسالة في {trip}', + 'notif.packing_tagged.title': 'مهمة التعبئة', + 'notif.packing_tagged.text': '{actor} عيّنك في {category} في {trip}', + 'notif.version_available.title': 'إصدار جديد متاح', + 'notif.version_available.text': 'TREK {version} متاح الآن', + 'notif.action.view_trip': 'عرض الرحلة', + 'notif.action.view_collab': 'عرض الرسائل', + 'notif.action.view_packing': 'عرض التعبئة', + 'notif.action.view_photos': 'عرض الصور', + 'notif.action.view_vacay': 'عرض Vacay', + 'notif.action.view_admin': 'الذهاب للإدارة', + 'notif.action.view': 'عرض', + 'notif.action.accept': 'قبول', + 'notif.action.decline': 'رفض', + 'notif.generic.title': 'إشعار', + 'notif.generic.text': 'لديك إشعار جديد', + 'notif.dev.unknown_event.title': '[DEV] حدث غير معروف', + 'notif.dev.unknown_event.text': 'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG', } export default ar diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 2aeffbf..7123387 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -490,8 +490,8 @@ const br: Record = { 'admin.addons.subtitle': 'Ative ou desative recursos para personalizar sua experiência no TREK.', 'admin.addons.catalog.memories.name': 'Memórias', 'admin.addons.catalog.memories.description': 'Álbuns de fotos compartilhados em cada viagem', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Listas', + 'admin.addons.catalog.packing.description': 'Listas de bagagem e tarefas a fazer para suas viagens', 'admin.addons.catalog.budget.name': 'Orçamento', 'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem', 'admin.addons.catalog.documents.name': 'Documentos', @@ -725,8 +725,8 @@ const br: Record = { 'trip.tabs.reservationsShort': 'Reservas', 'trip.tabs.packing': 'Lista de mala', 'trip.tabs.packingShort': 'Mala', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Listas', + 'trip.tabs.listsShort': 'Listas', 'trip.tabs.budget': 'Orçamento', 'trip.tabs.files': 'Arquivos', 'trip.loading': 'Carregando viagem...', @@ -921,11 +921,11 @@ const br: Record = { 'reservations.linkAssignment': 'Vincular à atribuição do dia', 'reservations.pickAssignment': 'Selecione uma atribuição do seu plano...', 'reservations.noAssignment': 'Sem vínculo (avulsa)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Preço', + 'reservations.budgetCategory': 'Categoria de orçamento', + 'reservations.budgetCategoryPlaceholder': 'ex. Transporte, Acomodação', + 'reservations.budgetCategoryAuto': 'Automático (pelo tipo de reserva)', + 'reservations.budgetHint': 'Uma entrada de orçamento será criada automaticamente ao salvar.', 'reservations.departureDate': 'Partida', 'reservations.arrivalDate': 'Chegada', 'reservations.departureTime': 'Hora partida', @@ -1571,38 +1571,105 @@ const br: Record = { 'notifications.test.tripText': 'Notificação de teste para a viagem "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': 'Lista de bagagem', + 'todo.subtab.todo': 'A fazer', + 'todo.completed': 'concluído(s)', + 'todo.filter.all': 'Todos', + 'todo.filter.open': 'Aberto', + 'todo.filter.done': 'Concluído', + 'todo.uncategorized': 'Sem categoria', + 'todo.namePlaceholder': 'Nome da tarefa', + 'todo.descriptionPlaceholder': 'Descrição (opcional)', + 'todo.unassigned': 'Não atribuído', + 'todo.noCategory': 'Sem categoria', + 'todo.hasDescription': 'Com descrição', + 'todo.addItem': 'Adicionar nova tarefa...', + 'todo.newCategory': 'Nome da categoria', + 'todo.addCategory': 'Adicionar categoria', + 'todo.newItem': 'Nova tarefa', + 'todo.empty': 'Nenhuma tarefa ainda. Adicione uma tarefa para começar!', + 'todo.filter.my': 'Minhas tarefas', + 'todo.filter.overdue': 'Atrasada', + 'todo.sidebar.tasks': 'Tarefas', + 'todo.sidebar.categories': 'Categorias', + 'todo.detail.title': 'Tarefa', + 'todo.detail.description': 'Descrição', + 'todo.detail.category': 'Categoria', + 'todo.detail.dueDate': 'Data de vencimento', + 'todo.detail.assignedTo': 'Atribuído a', + 'todo.detail.delete': 'Excluir', + 'todo.detail.save': 'Salvar alterações', + 'todo.detail.create': 'Criar tarefa', + 'todo.detail.priority': 'Prioridade', + 'todo.detail.noPriority': 'Nenhuma', + 'todo.sortByPrio': 'Prioridade', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Nova versão disponível', + 'settings.notificationPreferences.noChannels': 'Nenhum canal de notificação configurado. Peça a um administrador para configurar notificações por e-mail ou webhook.', + 'settings.webhookUrl.label': 'URL do webhook', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Insira a URL do seu webhook do Discord, Slack ou personalizado para receber notificações.', + 'settings.webhookUrl.save': 'Salvar', + 'settings.webhookUrl.saved': 'URL do webhook salva', + 'settings.webhookUrl.test': 'Testar', + 'settings.webhookUrl.testSuccess': 'Webhook de teste enviado com sucesso', + 'settings.webhookUrl.testFailed': 'Falha no webhook de teste', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'As notificações no aplicativo estão sempre ativas e não podem ser desativadas globalmente.', + 'admin.notifications.adminWebhookPanel.title': 'Webhook de admin', + 'admin.notifications.adminWebhookPanel.hint': 'Este webhook é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos webhooks de usuários e dispara automaticamente quando uma URL está configurada.', + 'admin.notifications.adminWebhookPanel.saved': 'URL do webhook de admin salva', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de teste enviado com sucesso', + 'admin.notifications.adminWebhookPanel.testFailed': 'Falha no webhook de teste', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'O webhook de admin dispara automaticamente quando uma URL está configurada', + 'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.', + 'admin.tabs.notifications': 'Notificações', + 'admin.tabs.notificationChannels': 'Canais de notificação', + 'admin.tabs.adminNotifications': 'Notificações de admin', + 'notifications.versionAvailable.title': 'Atualização disponível', + 'notifications.versionAvailable.text': 'TREK {version} já está disponível.', + 'notifications.versionAvailable.button': 'Ver detalhes', + 'notif.test.title': '[Teste] Notificação', + 'notif.test.simple.text': 'Esta é uma notificação de teste simples.', + 'notif.test.boolean.text': 'Você aceita esta notificação de teste?', + 'notif.test.navigate.text': 'Clique abaixo para ir ao painel.', + + // Notifications + 'notif.trip_invite.title': 'Convite para viagem', + 'notif.trip_invite.text': '{actor} convidou você para {trip}', + 'notif.booking_change.title': 'Reserva atualizada', + 'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}', + 'notif.trip_reminder.title': 'Lembrete de viagem', + 'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!', + 'notif.vacay_invite.title': 'Convite Vacay Fusion', + 'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias', + 'notif.photos_shared.title': 'Fotos compartilhadas', + 'notif.photos_shared.text': '{actor} compartilhou {count} foto(s) em {trip}', + 'notif.collab_message.title': 'Nova mensagem', + 'notif.collab_message.text': '{actor} enviou uma mensagem em {trip}', + 'notif.packing_tagged.title': 'Atribuição de bagagem', + 'notif.packing_tagged.text': '{actor} atribuiu você a {category} em {trip}', + 'notif.version_available.title': 'Nova versão disponível', + 'notif.version_available.text': 'TREK {version} está disponível', + 'notif.action.view_trip': 'Ver viagem', + 'notif.action.view_collab': 'Ver mensagens', + 'notif.action.view_packing': 'Ver bagagem', + 'notif.action.view_photos': 'Ver fotos', + 'notif.action.view_vacay': 'Ver Vacay', + 'notif.action.view_admin': 'Ir para admin', + 'notif.action.view': 'Ver', + 'notif.action.accept': 'Aceitar', + 'notif.action.decline': 'Recusar', + 'notif.generic.title': 'Notificação', + 'notif.generic.text': 'Você tem uma nova notificação', + 'notif.dev.unknown_event.title': '[DEV] Evento desconhecido', + 'notif.dev.unknown_event.text': 'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG', } export default br diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 0adbd2b..6f6608b 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -490,8 +490,8 @@ const cs: Record = { 'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.', 'admin.addons.catalog.memories.name': 'Fotky (Immich)', 'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Seznamy', + 'admin.addons.catalog.packing.description': 'Balicí seznamy a úkoly pro vaše výlety', 'admin.addons.catalog.budget.name': 'Rozpočet', 'admin.addons.catalog.budget.description': 'Sledování výdajů a plánování rozpočtu cesty', 'admin.addons.catalog.documents.name': 'Dokumenty', @@ -741,8 +741,8 @@ const cs: Record = { 'trip.tabs.reservationsShort': 'Rez.', 'trip.tabs.packing': 'Seznam věcí', 'trip.tabs.packingShort': 'Balení', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Seznamy', + 'trip.tabs.listsShort': 'Seznamy', 'trip.tabs.budget': 'Rozpočet', 'trip.tabs.files': 'Soubory', 'trip.loading': 'Načítání cesty...', @@ -938,11 +938,11 @@ const cs: Record = { 'reservations.linkAssignment': 'Propojit s přiřazením dne', 'reservations.pickAssignment': 'Vyberte přiřazení z vašeho plánu...', 'reservations.noAssignment': 'Bez propojení (samostatné)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Cena', + 'reservations.budgetCategory': 'Kategorie rozpočtu', + 'reservations.budgetCategoryPlaceholder': 'např. Doprava, Ubytování', + 'reservations.budgetCategoryAuto': 'Auto (podle typu rezervace)', + 'reservations.budgetHint': 'Při ukládání bude automaticky vytvořena položka rozpočtu.', 'reservations.departureDate': 'Odlet', 'reservations.arrivalDate': 'Přílet', 'reservations.departureTime': 'Čas odletu', @@ -1576,38 +1576,105 @@ const cs: Record = { 'notifications.test.tripText': 'Testovací oznámení pro výlet "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': 'Balicí seznam', + 'todo.subtab.todo': 'Úkoly', + 'todo.completed': 'dokončeno', + 'todo.filter.all': 'Vše', + 'todo.filter.open': 'Otevřené', + 'todo.filter.done': 'Hotové', + 'todo.uncategorized': 'Bez kategorie', + 'todo.namePlaceholder': 'Název úkolu', + 'todo.descriptionPlaceholder': 'Popis (volitelné)', + 'todo.unassigned': 'Nepřiřazeno', + 'todo.noCategory': 'Bez kategorie', + 'todo.hasDescription': 'Má popis', + 'todo.addItem': 'Přidat nový úkol...', + 'todo.newCategory': 'Název kategorie', + 'todo.addCategory': 'Přidat kategorii', + 'todo.newItem': 'Nový úkol', + 'todo.empty': 'Zatím žádné úkoly. Přidejte úkol a začněte!', + 'todo.filter.my': 'Moje úkoly', + 'todo.filter.overdue': 'Po termínu', + 'todo.sidebar.tasks': 'Úkoly', + 'todo.sidebar.categories': 'Kategorie', + 'todo.detail.title': 'Úkol', + 'todo.detail.description': 'Popis', + 'todo.detail.category': 'Kategorie', + 'todo.detail.dueDate': 'Termín splnění', + 'todo.detail.assignedTo': 'Přiřazeno', + 'todo.detail.delete': 'Smazat', + 'todo.detail.save': 'Uložit změny', + 'todo.detail.create': 'Vytvořit úkol', + 'todo.detail.priority': 'Priorita', + 'todo.detail.noPriority': 'Žádná', + 'todo.sortByPrio': 'Priorita', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Nová verze k dispozici', + 'settings.notificationPreferences.noChannels': 'Nejsou nakonfigurovány žádné kanály oznámení. Požádejte správce o nastavení e-mailových nebo webhook oznámení.', + 'settings.webhookUrl.label': 'URL webhooku', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Zadejte URL vašeho Discord, Slack nebo vlastního webhooku pro příjem oznámení.', + 'settings.webhookUrl.save': 'Uložit', + 'settings.webhookUrl.saved': 'URL webhooku uložena', + 'settings.webhookUrl.test': 'Otestovat', + 'settings.webhookUrl.testSuccess': 'Testovací webhook byl úspěšně odeslán', + 'settings.webhookUrl.testFailed': 'Testovací webhook selhal', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'In-app oznámení jsou vždy aktivní a nelze je globálně vypnout.', + 'admin.notifications.adminWebhookPanel.title': 'Admin webhook', + 'admin.notifications.adminWebhookPanel.hint': 'Tento webhook se používá výhradně pro admin oznámení (např. upozornění na verze). Je nezávislý na uživatelských webhooků a odesílá automaticky, pokud je nastavena URL.', + 'admin.notifications.adminWebhookPanel.saved': 'URL admin webhooku uložena', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Testovací webhook byl úspěšně odeslán', + 'admin.notifications.adminWebhookPanel.testFailed': 'Testovací webhook selhal', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook odesílá automaticky, pokud je nastavena URL', + 'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.', + 'admin.tabs.notifications': 'Oznámení', + 'admin.tabs.notificationChannels': 'Kanály oznámení', + 'admin.tabs.adminNotifications': 'Admin oznámení', + 'notifications.versionAvailable.title': 'Dostupná aktualizace', + 'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.', + 'notifications.versionAvailable.button': 'Zobrazit podrobnosti', + 'notif.test.title': '[Test] Oznámení', + 'notif.test.simple.text': 'Toto je jednoduché testovací oznámení.', + 'notif.test.boolean.text': 'Přijmete toto testovací oznámení?', + 'notif.test.navigate.text': 'Klikněte níže pro přechod na přehled.', + + // Notifications + 'notif.trip_invite.title': 'Pozvánka na výlet', + 'notif.trip_invite.text': '{actor} vás pozval na {trip}', + 'notif.booking_change.title': 'Rezervace aktualizována', + 'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}', + 'notif.trip_reminder.title': 'Připomínka výletu', + 'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!', + 'notif.vacay_invite.title': 'Pozvánka Vacay Fusion', + 'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů', + 'notif.photos_shared.title': 'Fotky sdíleny', + 'notif.photos_shared.text': '{actor} sdílel {count} foto v {trip}', + 'notif.collab_message.title': 'Nová zpráva', + 'notif.collab_message.text': '{actor} poslal zprávu v {trip}', + 'notif.packing_tagged.title': 'Přiřazení balení', + 'notif.packing_tagged.text': '{actor} vás přiřadil k {category} v {trip}', + 'notif.version_available.title': 'Nová verze dostupná', + 'notif.version_available.text': 'TREK {version} je nyní dostupný', + 'notif.action.view_trip': 'Zobrazit výlet', + 'notif.action.view_collab': 'Zobrazit zprávy', + 'notif.action.view_packing': 'Zobrazit balení', + 'notif.action.view_photos': 'Zobrazit fotky', + 'notif.action.view_vacay': 'Zobrazit Vacay', + 'notif.action.view_admin': 'Jít do adminu', + 'notif.action.view': 'Zobrazit', + 'notif.action.accept': 'Přijmout', + 'notif.action.decline': 'Odmítnout', + 'notif.generic.title': 'Oznámení', + 'notif.generic.text': 'Máte nové oznámení', + 'notif.dev.unknown_event.title': '[DEV] Neznámá událost', + 'notif.dev.unknown_event.text': 'Typ události "{event}" není registrován v EVENT_NOTIFICATION_CONFIG', } export default cs diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index a34bcea..394b5d8 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1605,6 +1605,73 @@ const de: Record = { 'todo.detail.priority': 'Priorität', 'todo.detail.noPriority': 'Keine', 'todo.detail.create': 'Aufgabe erstellen', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Neue Version verfügbar', + 'settings.notificationPreferences.noChannels': 'Keine Benachrichtigungskanäle konfiguriert. Bitte einen Administrator, E-Mail- oder Webhook-Benachrichtigungen einzurichten.', + 'settings.webhookUrl.label': 'Webhook URL', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Gib deine Discord-, Slack- oder benutzerdefinierte Webhook-URL ein, um Benachrichtigungen zu erhalten.', + 'settings.webhookUrl.save': 'Speichern', + 'settings.webhookUrl.saved': 'Webhook-URL gespeichert', + 'settings.webhookUrl.test': 'Testen', + 'settings.webhookUrl.testSuccess': 'Test-Webhook erfolgreich gesendet', + 'settings.webhookUrl.testFailed': 'Test-Webhook fehlgeschlagen', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'In-App-Benachrichtigungen sind immer aktiv und können nicht global deaktiviert werden.', + 'admin.notifications.adminWebhookPanel.title': 'Admin-Webhook', + 'admin.notifications.adminWebhookPanel.hint': 'Dieser Webhook wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Er ist unabhängig von den Benutzer-Webhooks und sendet automatisch, wenn eine URL konfiguriert ist.', + 'admin.notifications.adminWebhookPanel.saved': 'Admin-Webhook-URL gespeichert', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Test-Webhook erfolgreich gesendet', + 'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist', + 'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.', + 'admin.tabs.notifications': 'Benachrichtigungen', + 'admin.tabs.notificationChannels': 'Benachrichtigungskanäle', + 'admin.tabs.adminNotifications': 'Admin-Benachrichtigungen', + 'notifications.versionAvailable.title': 'Update verfügbar', + 'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.', + 'notifications.versionAvailable.button': 'Details anzeigen', + 'notif.test.title': '[Test] Benachrichtigung', + 'notif.test.simple.text': 'Dies ist eine einfache Testbenachrichtigung.', + 'notif.test.boolean.text': 'Akzeptierst du diese Testbenachrichtigung?', + 'notif.test.navigate.text': 'Klicke unten, um zum Dashboard zu navigieren.', + + // Notifications + 'notif.trip_invite.title': 'Reiseeinladung', + 'notif.trip_invite.text': '{actor} hat dich zu {trip} eingeladen', + 'notif.booking_change.title': 'Buchung aktualisiert', + 'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert', + 'notif.trip_reminder.title': 'Reiseerinnerung', + 'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!', + 'notif.vacay_invite.title': 'Vacay Fusion-Einladung', + 'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen', + 'notif.photos_shared.title': 'Fotos geteilt', + 'notif.photos_shared.text': '{actor} hat {count} Foto(s) in {trip} geteilt', + 'notif.collab_message.title': 'Neue Nachricht', + 'notif.collab_message.text': '{actor} hat eine Nachricht in {trip} gesendet', + 'notif.packing_tagged.title': 'Packlistenzuweisung', + 'notif.packing_tagged.text': '{actor} hat dich zu {category} in {trip} zugewiesen', + 'notif.version_available.title': 'Neue Version verfügbar', + 'notif.version_available.text': 'TREK {version} ist jetzt verfügbar', + 'notif.action.view_trip': 'Reise ansehen', + 'notif.action.view_collab': 'Nachrichten ansehen', + 'notif.action.view_packing': 'Packliste ansehen', + 'notif.action.view_photos': 'Fotos ansehen', + 'notif.action.view_vacay': 'Vacay ansehen', + 'notif.action.view_admin': 'Zum Admin', + 'notif.action.view': 'Ansehen', + 'notif.action.accept': 'Annehmen', + 'notif.action.decline': 'Ablehnen', + 'notif.generic.title': 'Benachrichtigung', + 'notif.generic.text': 'Du hast eine neue Benachrichtigung', + 'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis', + 'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert', } export default de diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 9b19cbc..fdc4276 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -163,23 +163,44 @@ const en: Record = { 'settings.notifyCollabMessage': 'Chat messages (Collab)', 'settings.notifyPackingTagged': 'Packing list: assignments', 'settings.notifyWebhook': 'Webhook notifications', + 'settings.notifyVersionAvailable': 'New version available', + 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.', + 'settings.webhookUrl.label': 'Webhook URL', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Enter your Discord, Slack, or custom webhook URL to receive notifications.', + 'settings.webhookUrl.save': 'Save', + 'settings.webhookUrl.saved': 'Webhook URL saved', + 'settings.webhookUrl.test': 'Test', + 'settings.webhookUrl.testSuccess': 'Test webhook sent successfully', + 'settings.webhookUrl.testFailed': 'Test webhook failed', 'admin.notifications.title': 'Notifications', 'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.', 'admin.notifications.none': 'Disabled', 'admin.notifications.email': 'Email (SMTP)', 'admin.notifications.webhook': 'Webhook', - 'admin.notifications.events': 'Notification Events', - 'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.', - 'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.', 'admin.notifications.save': 'Save notification settings', 'admin.notifications.saved': 'Notification settings saved', 'admin.notifications.testWebhook': 'Send test webhook', 'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully', 'admin.notifications.testWebhookFailed': 'Test webhook failed', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.', + 'admin.notifications.adminWebhookPanel.title': 'Admin Webhook', + 'admin.notifications.adminWebhookPanel.hint': 'This webhook is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user webhooks and always fires when set.', + 'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL saved', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully', + 'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured', + 'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).', 'admin.smtp.title': 'Email & Notifications', 'admin.smtp.hint': 'SMTP configuration for sending email notifications.', 'admin.smtp.testButton': 'Send test email', - 'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).', + 'admin.webhook.hint': 'Allow users to configure their own webhook URLs for notifications (Discord, Slack, etc.).', 'admin.smtp.testSuccess': 'Test email sent successfully', 'admin.smtp.testFailed': 'Test email failed', 'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.', @@ -383,6 +404,9 @@ const en: Record = { 'admin.tabs.users': 'Users', 'admin.tabs.categories': 'Categories', 'admin.tabs.backup': 'Backup', + 'admin.tabs.notifications': 'Notifications', + 'admin.tabs.notificationChannels': 'Notification Channels', + 'admin.tabs.adminNotifications': 'Admin Notifications', 'admin.tabs.audit': 'Audit log', 'admin.stats.users': 'Users', 'admin.stats.trips': 'Trips', @@ -1558,6 +1582,9 @@ const en: Record = { 'notifications.system': 'System', // Notification test keys (dev only) + 'notifications.versionAvailable.title': 'Update Available', + 'notifications.versionAvailable.text': 'TREK {version} is now available.', + 'notifications.versionAvailable.button': 'View Details', 'notifications.test.title': 'Test notification from {actor}', 'notifications.test.text': 'This is a simple test notification.', 'notifications.test.booleanTitle': '{actor} asks for your approval', @@ -1605,6 +1632,43 @@ const en: Record = { 'todo.detail.priority': 'Priority', 'todo.detail.noPriority': 'None', 'todo.detail.create': 'Create task', + + // Notifications — dev test events + 'notif.test.title': '[Test] Notification', + 'notif.test.simple.text': 'This is a simple test notification.', + 'notif.test.boolean.text': 'Do you accept this test notification?', + 'notif.test.navigate.text': 'Click below to navigate to the dashboard.', + + // Notifications + 'notif.trip_invite.title': 'Trip Invitation', + 'notif.trip_invite.text': '{actor} invited you to {trip}', + 'notif.booking_change.title': 'Booking Updated', + 'notif.booking_change.text': '{actor} updated a booking in {trip}', + 'notif.trip_reminder.title': 'Trip Reminder', + 'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!', + 'notif.vacay_invite.title': 'Vacay Fusion Invite', + 'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans', + 'notif.photos_shared.title': 'Photos Shared', + 'notif.photos_shared.text': '{actor} shared {count} photo(s) in {trip}', + 'notif.collab_message.title': 'New Message', + 'notif.collab_message.text': '{actor} sent a message in {trip}', + 'notif.packing_tagged.title': 'Packing Assignment', + 'notif.packing_tagged.text': '{actor} assigned you to {category} in {trip}', + 'notif.version_available.title': 'New Version Available', + 'notif.version_available.text': 'TREK {version} is now available', + 'notif.action.view_trip': 'View Trip', + 'notif.action.view_collab': 'View Messages', + 'notif.action.view_packing': 'View Packing', + 'notif.action.view_photos': 'View Photos', + 'notif.action.view_vacay': 'View Vacay', + 'notif.action.view_admin': 'Go to Admin', + 'notif.action.view': 'View', + 'notif.action.accept': 'Accept', + 'notif.action.decline': 'Decline', + 'notif.generic.title': 'Notification', + 'notif.generic.text': 'You have a new notification', + 'notif.dev.unknown_event.title': '[DEV] Unknown Event', + 'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG', } export default en diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 35f9712..2f07691 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -717,8 +717,8 @@ const es: Record = { 'trip.tabs.reservationsShort': 'Reservas', 'trip.tabs.packing': 'Lista de equipaje', 'trip.tabs.packingShort': 'Equipaje', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Listas', + 'trip.tabs.listsShort': 'Listas', 'trip.tabs.budget': 'Presupuesto', 'trip.tabs.files': 'Archivos', 'trip.loading': 'Cargando viaje...', @@ -897,11 +897,11 @@ const es: Record = { 'reservations.linkAssignment': 'Vincular a una asignación del día', 'reservations.pickAssignment': 'Selecciona una asignación de tu plan...', 'reservations.noAssignment': 'Sin vínculo (independiente)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Precio', + 'reservations.budgetCategory': 'Categoría de presupuesto', + 'reservations.budgetCategoryPlaceholder': 'ej. Transporte, Alojamiento', + 'reservations.budgetCategoryAuto': 'Automático (según tipo de reserva)', + 'reservations.budgetHint': 'Se creará automáticamente una entrada presupuestaria al guardar.', 'reservations.departureDate': 'Salida', 'reservations.arrivalDate': 'Llegada', 'reservations.departureTime': 'Hora salida', @@ -1193,8 +1193,8 @@ const es: Record = { 'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Protocolo de contexto de modelo para integración con asistentes de IA', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Listas', + 'admin.addons.catalog.packing.description': 'Listas de equipaje y tareas pendientes para tus viajes', 'admin.addons.catalog.budget.name': 'Presupuesto', 'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje', 'admin.addons.catalog.documents.name': 'Documentos', @@ -1578,38 +1578,105 @@ const es: Record = { 'notifications.test.tripText': 'Notificación de prueba para el viaje "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': 'Lista de equipaje', + 'todo.subtab.todo': 'Por hacer', + 'todo.completed': 'completado(s)', + 'todo.filter.all': 'Todo', + 'todo.filter.open': 'Abierto', + 'todo.filter.done': 'Hecho', + 'todo.uncategorized': 'Sin categoría', + 'todo.namePlaceholder': 'Nombre de la tarea', + 'todo.descriptionPlaceholder': 'Descripción (opcional)', + 'todo.unassigned': 'Sin asignar', + 'todo.noCategory': 'Sin categoría', + 'todo.hasDescription': 'Con descripción', + 'todo.addItem': 'Añadir nueva tarea...', + 'todo.newCategory': 'Nombre de la categoría', + 'todo.addCategory': 'Añadir categoría', + 'todo.newItem': 'Nueva tarea', + 'todo.empty': 'Aún no hay tareas. ¡Añade una tarea para empezar!', + 'todo.filter.my': 'Mis tareas', + 'todo.filter.overdue': 'Vencida', + 'todo.sidebar.tasks': 'Tareas', + 'todo.sidebar.categories': 'Categorías', + 'todo.detail.title': 'Tarea', + 'todo.detail.description': 'Descripción', + 'todo.detail.category': 'Categoría', + 'todo.detail.dueDate': 'Fecha límite', + 'todo.detail.assignedTo': 'Asignado a', + 'todo.detail.delete': 'Eliminar', + 'todo.detail.save': 'Guardar cambios', + 'todo.detail.create': 'Crear tarea', + 'todo.detail.priority': 'Prioridad', + 'todo.detail.noPriority': 'Ninguna', + 'todo.sortByPrio': 'Prioridad', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Nueva versión disponible', + 'settings.notificationPreferences.noChannels': 'No hay canales de notificación configurados. Pide a un administrador que configure notificaciones por correo o webhook.', + 'settings.webhookUrl.label': 'URL del webhook', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Introduce tu URL de webhook de Discord, Slack o personalizada para recibir notificaciones.', + 'settings.webhookUrl.save': 'Guardar', + 'settings.webhookUrl.saved': 'URL del webhook guardada', + 'settings.webhookUrl.test': 'Probar', + 'settings.webhookUrl.testSuccess': 'Webhook de prueba enviado correctamente', + 'settings.webhookUrl.testFailed': 'Error al enviar el webhook de prueba', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'Las notificaciones in-app siempre están activas y no se pueden desactivar globalmente.', + 'admin.notifications.adminWebhookPanel.title': 'Webhook de admin', + 'admin.notifications.adminWebhookPanel.hint': 'Este webhook se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los webhooks de usuario y se activa automáticamente si hay una URL configurada.', + 'admin.notifications.adminWebhookPanel.saved': 'URL del webhook de admin guardada', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de prueba enviado correctamente', + 'admin.notifications.adminWebhookPanel.testFailed': 'Error al enviar el webhook de prueba', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'El webhook de admin se activa automáticamente si hay una URL configurada', + 'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.', + 'admin.tabs.notifications': 'Notificaciones', + 'admin.tabs.notificationChannels': 'Canales de notificación', + 'admin.tabs.adminNotifications': 'Notificaciones de admin', + 'notifications.versionAvailable.title': 'Actualización disponible', + 'notifications.versionAvailable.text': 'TREK {version} ya está disponible.', + 'notifications.versionAvailable.button': 'Ver detalles', + 'notif.test.title': '[Test] Notificación', + 'notif.test.simple.text': 'Esta es una notificación de prueba simple.', + 'notif.test.boolean.text': '¿Aceptas esta notificación de prueba?', + 'notif.test.navigate.text': 'Haz clic abajo para ir al panel de control.', + + // Notifications + 'notif.trip_invite.title': 'Invitación al viaje', + 'notif.trip_invite.text': '{actor} te invitó a {trip}', + 'notif.booking_change.title': 'Reserva actualizada', + 'notif.booking_change.text': '{actor} actualizó una reserva en {trip}', + 'notif.trip_reminder.title': 'Recordatorio de viaje', + 'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!', + 'notif.vacay_invite.title': 'Invitación Vacay Fusion', + 'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones', + 'notif.photos_shared.title': 'Fotos compartidas', + 'notif.photos_shared.text': '{actor} compartió {count} foto(s) en {trip}', + 'notif.collab_message.title': 'Nuevo mensaje', + 'notif.collab_message.text': '{actor} envió un mensaje en {trip}', + 'notif.packing_tagged.title': 'Asignación de equipaje', + 'notif.packing_tagged.text': '{actor} te asignó a {category} en {trip}', + 'notif.version_available.title': 'Nueva versión disponible', + 'notif.version_available.text': 'TREK {version} ya está disponible', + 'notif.action.view_trip': 'Ver viaje', + 'notif.action.view_collab': 'Ver mensajes', + 'notif.action.view_packing': 'Ver equipaje', + 'notif.action.view_photos': 'Ver fotos', + 'notif.action.view_vacay': 'Ver Vacay', + 'notif.action.view_admin': 'Ir al admin', + 'notif.action.view': 'Ver', + 'notif.action.accept': 'Aceptar', + 'notif.action.decline': 'Rechazar', + 'notif.generic.title': 'Notificación', + 'notif.generic.text': 'Tienes una nueva notificación', + 'notif.dev.unknown_event.title': '[DEV] Evento desconocido', + 'notif.dev.unknown_event.text': 'El tipo de evento "{event}" no está registrado en EVENT_NOTIFICATION_CONFIG', } export default es diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 5676a20..bbc2683 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -491,8 +491,8 @@ const fr: Record = { 'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Protocole de contexte de modèle pour l\'intégration d\'assistants IA', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Listes', + 'admin.addons.catalog.packing.description': 'Listes de bagages et tâches à faire pour vos voyages', 'admin.addons.catalog.budget.name': 'Budget', 'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage', 'admin.addons.catalog.documents.name': 'Documents', @@ -740,8 +740,8 @@ const fr: Record = { 'trip.tabs.reservationsShort': 'Résa', 'trip.tabs.packing': 'Liste de bagages', 'trip.tabs.packingShort': 'Bagages', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Listes', + 'trip.tabs.listsShort': 'Listes', 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Fichiers', 'trip.loading': 'Chargement du voyage…', @@ -936,11 +936,11 @@ const fr: Record = { 'reservations.linkAssignment': 'Lier à l\'affectation du jour', 'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…', 'reservations.noAssignment': 'Aucun lien (autonome)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Prix', + 'reservations.budgetCategory': 'Catégorie budgétaire', + 'reservations.budgetCategoryPlaceholder': 'ex. Transport, Hébergement', + 'reservations.budgetCategoryAuto': 'Auto (selon le type de réservation)', + 'reservations.budgetHint': 'Une entrée budgétaire sera créée automatiquement lors de l\'enregistrement.', 'reservations.departureDate': 'Départ', 'reservations.arrivalDate': 'Arrivée', 'reservations.departureTime': 'Heure dép.', @@ -1572,38 +1572,105 @@ const fr: Record = { 'notifications.test.tripText': 'Notification de test pour le voyage "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', + 'todo.subtab.packing': 'Liste de bagages', + 'todo.subtab.todo': 'À faire', + 'todo.completed': 'terminé(s)', + 'todo.filter.all': 'Tout', + 'todo.filter.open': 'En cours', + 'todo.filter.done': 'Terminé', + 'todo.uncategorized': 'Sans catégorie', + 'todo.namePlaceholder': 'Nom de la tâche', + 'todo.descriptionPlaceholder': 'Description (facultative)', + 'todo.unassigned': 'Non assigné', + 'todo.noCategory': 'Aucune catégorie', + 'todo.hasDescription': 'Avec description', + 'todo.addItem': 'Ajouter une tâche...', + 'todo.newCategory': 'Nom de la catégorie', + 'todo.addCategory': 'Ajouter une catégorie', + 'todo.newItem': 'Nouvelle tâche', + 'todo.empty': 'Aucune tâche pour l\'instant. Ajoutez une tâche pour commencer !', + 'todo.filter.my': 'Mes tâches', + 'todo.filter.overdue': 'En retard', + 'todo.sidebar.tasks': 'Tâches', + 'todo.sidebar.categories': 'Catégories', + 'todo.detail.title': 'Tâche', 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.detail.category': 'Catégorie', + 'todo.detail.dueDate': 'Date d\'échéance', + 'todo.detail.assignedTo': 'Assigné à', + 'todo.detail.delete': 'Supprimer', + 'todo.detail.save': 'Enregistrer les modifications', + 'todo.detail.create': 'Créer la tâche', + 'todo.detail.priority': 'Priorité', + 'todo.detail.noPriority': 'Aucune', + 'todo.sortByPrio': 'Priorité', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Nouvelle version disponible', + 'settings.notificationPreferences.noChannels': 'Aucun canal de notification n\'est configuré. Demandez à un administrateur de configurer les notifications par e-mail ou webhook.', + 'settings.webhookUrl.label': 'URL du webhook', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Entrez votre URL de webhook Discord, Slack ou personnalisée pour recevoir des notifications.', + 'settings.webhookUrl.save': 'Enregistrer', + 'settings.webhookUrl.saved': 'URL du webhook enregistrée', + 'settings.webhookUrl.test': 'Tester', + 'settings.webhookUrl.testSuccess': 'Webhook de test envoyé avec succès', + 'settings.webhookUrl.testFailed': 'Échec du webhook de test', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'Les notifications in-app sont toujours actives et ne peuvent pas être désactivées globalement.', + 'admin.notifications.adminWebhookPanel.title': 'Webhook admin', + 'admin.notifications.adminWebhookPanel.hint': 'Ce webhook est utilisé exclusivement pour les notifications admin (ex. alertes de version). Il est séparé des webhooks utilisateur et s\'active automatiquement si une URL est configurée.', + 'admin.notifications.adminWebhookPanel.saved': 'URL du webhook admin enregistrée', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de test envoyé avec succès', + 'admin.notifications.adminWebhookPanel.testFailed': 'Échec du webhook de test', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Le webhook admin s\'active automatiquement si une URL est configurée', + 'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.', + 'admin.tabs.notifications': 'Notifications', + 'admin.tabs.notificationChannels': 'Canaux de notification', + 'admin.tabs.adminNotifications': 'Notifications admin', + 'notifications.versionAvailable.title': 'Mise à jour disponible', + 'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.', + 'notifications.versionAvailable.button': 'Voir les détails', + 'notif.test.title': '[Test] Notification', + 'notif.test.simple.text': 'Ceci est une simple notification de test.', + 'notif.test.boolean.text': 'Acceptez-vous cette notification de test ?', + 'notif.test.navigate.text': 'Cliquez ci-dessous pour accéder au tableau de bord.', + + // Notifications + 'notif.trip_invite.title': 'Invitation au voyage', + 'notif.trip_invite.text': '{actor} vous a invité à {trip}', + 'notif.booking_change.title': 'Réservation mise à jour', + 'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}', + 'notif.trip_reminder.title': 'Rappel de voyage', + 'notif.trip_reminder.text': 'Votre voyage {trip} approche !', + 'notif.vacay_invite.title': 'Invitation Vacay Fusion', + 'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances', + 'notif.photos_shared.title': 'Photos partagées', + 'notif.photos_shared.text': '{actor} a partagé {count} photo(s) dans {trip}', + 'notif.collab_message.title': 'Nouveau message', + 'notif.collab_message.text': '{actor} a envoyé un message dans {trip}', + 'notif.packing_tagged.title': 'Affectation bagages', + 'notif.packing_tagged.text': '{actor} vous a assigné à {category} dans {trip}', + 'notif.version_available.title': 'Nouvelle version disponible', + 'notif.version_available.text': 'TREK {version} est maintenant disponible', + 'notif.action.view_trip': 'Voir le voyage', + 'notif.action.view_collab': 'Voir les messages', + 'notif.action.view_packing': 'Voir les bagages', + 'notif.action.view_photos': 'Voir les photos', + 'notif.action.view_vacay': 'Voir Vacay', + 'notif.action.view_admin': 'Aller à l\'admin', + 'notif.action.view': 'Voir', + 'notif.action.accept': 'Accepter', + 'notif.action.decline': 'Refuser', + 'notif.generic.title': 'Notification', + 'notif.generic.text': 'Vous avez une nouvelle notification', + 'notif.dev.unknown_event.title': '[DEV] Événement inconnu', + 'notif.dev.unknown_event.text': 'Le type d\'événement "{event}" n\'est pas enregistré dans EVENT_NOTIFICATION_CONFIG', } export default fr diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 769c88a..a631658 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -488,8 +488,8 @@ const hu: Record = { 'admin.tabs.addons': 'Bővítmények', 'admin.addons.title': 'Bővítmények', 'admin.addons.subtitle': 'Funkciók engedélyezése vagy letiltása a TREK testreszabásához.', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Listák', + 'admin.addons.catalog.packing.description': 'Csomagolási listák és teendők az utazásaidhoz', 'admin.addons.catalog.budget.name': 'Költségvetés', 'admin.addons.catalog.budget.description': 'Kiadások nyomon követése és az utazási költségvetés tervezése', 'admin.addons.catalog.documents.name': 'Dokumentumok', @@ -741,8 +741,8 @@ const hu: Record = { 'trip.tabs.reservationsShort': 'Foglalás', 'trip.tabs.packing': 'Csomagolási lista', 'trip.tabs.packingShort': 'Csomag', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Listák', + 'trip.tabs.listsShort': 'Listák', 'trip.tabs.budget': 'Költségvetés', 'trip.tabs.files': 'Fájlok', 'trip.loading': 'Utazás betöltése...', @@ -937,11 +937,11 @@ const hu: Record = { 'reservations.linkAssignment': 'Összekapcsolás napi tervvel', 'reservations.pickAssignment': 'Válassz hozzárendelést a tervedből...', 'reservations.noAssignment': 'Nincs összekapcsolás (önálló)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Ár', + 'reservations.budgetCategory': 'Költségvetési kategória', + 'reservations.budgetCategoryPlaceholder': 'pl. Közlekedés, Szállás', + 'reservations.budgetCategoryAuto': 'Automatikus (foglalás típusa alapján)', + 'reservations.budgetHint': 'Mentéskor automatikusan létrejön egy költségvetési tétel.', 'reservations.departureDate': 'Indulás', 'reservations.arrivalDate': 'Érkezés', 'reservations.departureTime': 'Indulási idő', @@ -1573,38 +1573,105 @@ const hu: Record = { 'notifications.test.tripText': 'Teszt értesítés a(z) "{trip}" utazáshoz.', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': 'Csomagolási lista', + 'todo.subtab.todo': 'Teendők', + 'todo.completed': 'kész', + 'todo.filter.all': 'Mind', + 'todo.filter.open': 'Nyitott', + 'todo.filter.done': 'Kész', + 'todo.uncategorized': 'Kategória nélküli', + 'todo.namePlaceholder': 'Feladat neve', + 'todo.descriptionPlaceholder': 'Leírás (opcionális)', + 'todo.unassigned': 'Nem hozzárendelt', + 'todo.noCategory': 'Nincs kategória', + 'todo.hasDescription': 'Van leírás', + 'todo.addItem': 'Új feladat hozzáadása...', + 'todo.newCategory': 'Kategória neve', + 'todo.addCategory': 'Kategória hozzáadása', + 'todo.newItem': 'Új feladat', + 'todo.empty': 'Még nincsenek feladatok. Adj hozzá egyet a kezdéshez!', + 'todo.filter.my': 'Saját feladataim', + 'todo.filter.overdue': 'Lejárt', + 'todo.sidebar.tasks': 'Feladatok', + 'todo.sidebar.categories': 'Kategóriák', + 'todo.detail.title': 'Feladat', + 'todo.detail.description': 'Leírás', + 'todo.detail.category': 'Kategória', + 'todo.detail.dueDate': 'Határidő', + 'todo.detail.assignedTo': 'Hozzárendelve', + 'todo.detail.delete': 'Törlés', + 'todo.detail.save': 'Módosítások mentése', + 'todo.detail.create': 'Feladat létrehozása', + 'todo.detail.priority': 'Prioritás', + 'todo.detail.noPriority': 'Nincs', + 'todo.sortByPrio': 'Prioritás', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Új verzió elérhető', + 'settings.notificationPreferences.noChannels': 'Nincsenek értesítési csatornák beállítva. Kérd meg a rendszergazdát, hogy állítson be e-mail vagy webhook értesítéseket.', + 'settings.webhookUrl.label': 'Webhook URL', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Adja meg a Discord, Slack vagy egyéni webhook URL-jét az értesítések fogadásához.', + 'settings.webhookUrl.save': 'Mentés', + 'settings.webhookUrl.saved': 'Webhook URL mentve', + 'settings.webhookUrl.test': 'Teszt', + 'settings.webhookUrl.testSuccess': 'Teszt webhook sikeresen elküldve', + 'settings.webhookUrl.testFailed': 'Teszt webhook sikertelen', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'Az alkalmazáson belüli értesítések mindig aktívak, és globálisan nem kapcsolhatók ki.', + 'admin.notifications.adminWebhookPanel.title': 'Admin webhook', + 'admin.notifications.adminWebhookPanel.hint': 'Ez a webhook kizárólag admin értesítésekhez használatos (pl. verziófrissítési figyelmeztetések). Független a felhasználói webhookoktól, és automatikusan küld, ha URL van beállítva.', + 'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL mentve', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Teszt webhook sikeresen elküldve', + 'admin.notifications.adminWebhookPanel.testFailed': 'Teszt webhook sikertelen', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Az admin webhook automatikusan küld, ha URL van beállítva', + 'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.', + 'admin.tabs.notifications': 'Értesítések', + 'admin.tabs.notificationChannels': 'Értesítési csatornák', + 'admin.tabs.adminNotifications': 'Admin értesítések', + 'notifications.versionAvailable.title': 'Elérhető frissítés', + 'notifications.versionAvailable.text': 'A TREK {version} már elérhető.', + 'notifications.versionAvailable.button': 'Részletek megtekintése', + 'notif.test.title': '[Teszt] Értesítés', + 'notif.test.simple.text': 'Ez egy egyszerű teszt értesítés.', + 'notif.test.boolean.text': 'Elfogadod ezt a teszt értesítést?', + 'notif.test.navigate.text': 'Kattints alább az irányítópultra navigáláshoz.', + + // Notifications + 'notif.trip_invite.title': 'Utazásra meghívó', + 'notif.trip_invite.text': '{actor} meghívott a(z) {trip} utazásra', + 'notif.booking_change.title': 'Foglalás frissítve', + 'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban', + 'notif.trip_reminder.title': 'Utazás emlékeztető', + 'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!', + 'notif.vacay_invite.title': 'Vacay Fusion meghívó', + 'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához', + 'notif.photos_shared.title': 'Fotók megosztva', + 'notif.photos_shared.text': '{actor} {count} fotót osztott meg a(z) {trip} utazásban', + 'notif.collab_message.title': 'Új üzenet', + 'notif.collab_message.text': '{actor} üzenetet küldött a(z) {trip} utazásban', + 'notif.packing_tagged.title': 'Csomagolási feladat', + 'notif.packing_tagged.text': '{actor} hozzárendelte Önt a {category} kategóriához a(z) {trip} utazásban', + 'notif.version_available.title': 'Új verzió elérhető', + 'notif.version_available.text': 'A TREK {version} elérhető', + 'notif.action.view_trip': 'Utazás megtekintése', + 'notif.action.view_collab': 'Üzenetek megtekintése', + 'notif.action.view_packing': 'Csomagolás megtekintése', + 'notif.action.view_photos': 'Fotók megtekintése', + 'notif.action.view_vacay': 'Vacay megtekintése', + 'notif.action.view_admin': 'Admin megnyitása', + 'notif.action.view': 'Megtekintés', + 'notif.action.accept': 'Elfogadás', + 'notif.action.decline': 'Elutasítás', + 'notif.generic.title': 'Értesítés', + 'notif.generic.text': 'Új értesítésed érkezett', + 'notif.dev.unknown_event.title': '[DEV] Ismeretlen esemény', + 'notif.dev.unknown_event.text': 'A(z) "{event}" eseménytípus nincs regisztrálva az EVENT_NOTIFICATION_CONFIG-ban', } export default hu diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 7f282bd..a2e1819 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -487,8 +487,8 @@ const it: Record = { 'admin.tabs.addons': 'Moduli', 'admin.addons.title': 'Moduli', 'admin.addons.subtitle': 'Abilita o disabilita le funzionalità per personalizzare la tua esperienza TREK.', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Liste', + 'admin.addons.catalog.packing.description': 'Liste di imballaggio e attività da svolgere per i tuoi viaggi', 'admin.addons.catalog.budget.name': 'Budget', 'admin.addons.catalog.budget.description': 'Tieni traccia delle spese e pianifica il budget del tuo viaggio', 'admin.addons.catalog.documents.name': 'Documenti', @@ -741,8 +741,8 @@ const it: Record = { 'trip.tabs.reservationsShort': 'Pren.', 'trip.tabs.packing': 'Lista valigia', 'trip.tabs.packingShort': 'Valigia', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Liste', + 'trip.tabs.listsShort': 'Liste', 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'File', 'trip.loading': 'Caricamento viaggio...', @@ -937,11 +937,11 @@ const it: Record = { 'reservations.linkAssignment': 'Collega all\'assegnazione del giorno', 'reservations.pickAssignment': 'Seleziona un\'assegnazione dal tuo programma...', 'reservations.noAssignment': 'Nessun collegamento (autonomo)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Prezzo', + 'reservations.budgetCategory': 'Categoria budget', + 'reservations.budgetCategoryPlaceholder': 'es. Trasporto, Alloggio', + 'reservations.budgetCategoryAuto': 'Auto (dal tipo di prenotazione)', + 'reservations.budgetHint': 'Una voce di budget verrà creata automaticamente al salvataggio.', 'reservations.departureDate': 'Partenza', 'reservations.arrivalDate': 'Arrivo', 'reservations.departureTime': 'Ora part.', @@ -1002,7 +1002,7 @@ const it: Record = { // Files 'files.title': 'File', 'files.count': '{count} file', - 'files.countSingular': '1 file', + 'files.countSingular': '1 documento', 'files.uploaded': '{count} caricati', 'files.uploadError': 'Caricamento non riuscito', 'files.dropzone': 'Trascina qui i file', @@ -1573,38 +1573,105 @@ const it: Record = { 'notifications.test.tripText': 'Notifica di test per il viaggio "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': 'Lista di imballaggio', + 'todo.subtab.todo': 'Da fare', + 'todo.completed': 'completato/i', + 'todo.filter.all': 'Tutti', + 'todo.filter.open': 'Aperto', + 'todo.filter.done': 'Fatto', + 'todo.uncategorized': 'Senza categoria', + 'todo.namePlaceholder': 'Nome attività', + 'todo.descriptionPlaceholder': 'Descrizione (facoltativa)', + 'todo.unassigned': 'Non assegnato', + 'todo.noCategory': 'Nessuna categoria', + 'todo.hasDescription': 'Ha descrizione', + 'todo.addItem': 'Aggiungi nuova attività...', + 'todo.newCategory': 'Nome categoria', + 'todo.addCategory': 'Aggiungi categoria', + 'todo.newItem': 'Nuova attività', + 'todo.empty': 'Nessuna attività ancora. Aggiungi un\'attività per iniziare!', + 'todo.filter.my': 'Le mie attività', + 'todo.filter.overdue': 'Scaduta', + 'todo.sidebar.tasks': 'Attività', + 'todo.sidebar.categories': 'Categorie', + 'todo.detail.title': 'Attività', + 'todo.detail.description': 'Descrizione', + 'todo.detail.category': 'Categoria', + 'todo.detail.dueDate': 'Scadenza', + 'todo.detail.assignedTo': 'Assegnato a', + 'todo.detail.delete': 'Elimina', + 'todo.detail.save': 'Salva modifiche', + 'todo.detail.create': 'Crea attività', + 'todo.detail.priority': 'Priorità', + 'todo.detail.noPriority': 'Nessuna', + 'todo.sortByPrio': 'Priorità', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Nuova versione disponibile', + 'settings.notificationPreferences.noChannels': 'Nessun canale di notifica configurato. Chiedi a un amministratore di configurare notifiche via e-mail o webhook.', + 'settings.webhookUrl.label': 'URL webhook', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Inserisci il tuo URL webhook Discord, Slack o personalizzato per ricevere notifiche.', + 'settings.webhookUrl.save': 'Salva', + 'settings.webhookUrl.saved': 'URL webhook salvato', + 'settings.webhookUrl.test': 'Test', + 'settings.webhookUrl.testSuccess': 'Webhook di test inviato con successo', + 'settings.webhookUrl.testFailed': 'Invio webhook di test fallito', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'Le notifiche in-app sono sempre attive e non possono essere disabilitate globalmente.', + 'admin.notifications.adminWebhookPanel.title': 'Webhook admin', + 'admin.notifications.adminWebhookPanel.hint': 'Questo webhook viene usato esclusivamente per le notifiche admin (es. avvisi di versione). È separato dai webhook utente e si attiva automaticamente quando è configurato un URL.', + 'admin.notifications.adminWebhookPanel.saved': 'URL webhook admin salvato', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook di test inviato con successo', + 'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL', + 'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.', + 'admin.tabs.notifications': 'Notifications', + 'admin.tabs.notificationChannels': 'Canali di notifica', + 'admin.tabs.adminNotifications': 'Notifiche admin', + 'notifications.versionAvailable.title': 'Aggiornamento disponibile', + 'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.', + 'notifications.versionAvailable.button': 'Visualizza dettagli', + 'notif.test.title': '[Test] Notifica', + 'notif.test.simple.text': 'Questa è una semplice notifica di test.', + 'notif.test.boolean.text': 'Accetti questa notifica di test?', + 'notif.test.navigate.text': 'Clicca qui sotto per accedere alla dashboard.', + + // Notifications + 'notif.trip_invite.title': 'Invito al viaggio', + 'notif.trip_invite.text': '{actor} ti ha invitato a {trip}', + 'notif.booking_change.title': 'Prenotazione aggiornata', + 'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}', + 'notif.trip_reminder.title': 'Promemoria viaggio', + 'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!', + 'notif.vacay_invite.title': 'Invito Vacay Fusion', + 'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza', + 'notif.photos_shared.title': 'Foto condivise', + 'notif.photos_shared.text': '{actor} ha condiviso {count} foto in {trip}', + 'notif.collab_message.title': 'Nuovo messaggio', + 'notif.collab_message.text': '{actor} ha inviato un messaggio in {trip}', + 'notif.packing_tagged.title': 'Assegnazione bagagli', + 'notif.packing_tagged.text': '{actor} ti ha assegnato a {category} in {trip}', + 'notif.version_available.title': 'Nuova versione disponibile', + 'notif.version_available.text': 'TREK {version} è ora disponibile', + 'notif.action.view_trip': 'Vedi viaggio', + 'notif.action.view_collab': 'Vedi messaggi', + 'notif.action.view_packing': 'Vedi bagagli', + 'notif.action.view_photos': 'Vedi foto', + 'notif.action.view_vacay': 'Vedi Vacay', + 'notif.action.view_admin': 'Vai all\'admin', + 'notif.action.view': 'Vedi', + 'notif.action.accept': 'Accetta', + 'notif.action.decline': 'Rifiuta', + 'notif.generic.title': 'Notifica', + 'notif.generic.text': 'Hai una nuova notifica', + 'notif.dev.unknown_event.title': '[DEV] Evento sconosciuto', + 'notif.dev.unknown_event.text': 'Il tipo di evento "{event}" non è registrato in EVENT_NOTIFICATION_CONFIG', } export default it diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 8a307c3..098d0d2 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -492,8 +492,8 @@ const nl: Record = { 'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Model Context Protocol voor AI-assistent integratie', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Lijsten', + 'admin.addons.catalog.packing.description': 'Paklijsten en to-dotaken voor je reizen', 'admin.addons.catalog.budget.name': 'Budget', 'admin.addons.catalog.budget.description': 'Houd uitgaven bij en plan je reisbudget', 'admin.addons.catalog.documents.name': 'Documenten', @@ -740,8 +740,8 @@ const nl: Record = { 'trip.tabs.reservationsShort': 'Boek', 'trip.tabs.packing': 'Paklijst', 'trip.tabs.packingShort': 'Inpakken', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Lijsten', + 'trip.tabs.listsShort': 'Lijsten', 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Bestanden', 'trip.loading': 'Reis laden...', @@ -936,11 +936,11 @@ const nl: Record = { 'reservations.linkAssignment': 'Koppelen aan dagtoewijzing', 'reservations.pickAssignment': 'Selecteer een toewijzing uit je plan...', 'reservations.noAssignment': 'Geen koppeling (zelfstandig)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Prijs', + 'reservations.budgetCategory': 'Budgetcategorie', + 'reservations.budgetCategoryPlaceholder': 'bijv. Transport, Accommodatie', + 'reservations.budgetCategoryAuto': 'Automatisch (op basis van boekingstype)', + 'reservations.budgetHint': 'Er wordt automatisch een budgetpost aangemaakt bij het opslaan.', 'reservations.departureDate': 'Vertrek', 'reservations.arrivalDate': 'Aankomst', 'reservations.departureTime': 'Vertrektijd', @@ -1406,7 +1406,7 @@ const nl: Record = { // Collab Addon 'collab.tabs.chat': 'Chat', 'collab.tabs.notes': 'Notities', - 'collab.tabs.polls': 'Polls', + 'collab.tabs.polls': 'Peilingen', 'collab.whatsNext.title': 'Wat komt er', 'collab.whatsNext.today': 'Vandaag', 'collab.whatsNext.tomorrow': 'Morgen', @@ -1452,7 +1452,7 @@ const nl: Record = { 'collab.notes.attachFiles': 'Bestanden bijvoegen', 'collab.notes.noCategoriesYet': 'Nog geen categorieën', 'collab.notes.emptyDesc': 'Maak een notitie om te beginnen', - 'collab.polls.title': 'Polls', + 'collab.polls.title': 'Peilingen', 'collab.polls.new': 'Nieuwe poll', 'collab.polls.empty': 'Nog geen polls', 'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen', @@ -1572,38 +1572,105 @@ const nl: Record = { 'notifications.test.tripText': 'Testmelding voor reis "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', + 'todo.subtab.packing': 'Paklijst', + 'todo.subtab.todo': 'Taken', + 'todo.completed': 'voltooid', + 'todo.filter.all': 'Alles', 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.filter.done': 'Klaar', + 'todo.uncategorized': 'Zonder categorie', + 'todo.namePlaceholder': 'Taaknaam', + 'todo.descriptionPlaceholder': 'Beschrijving (optioneel)', + 'todo.unassigned': 'Niet toegewezen', + 'todo.noCategory': 'Geen categorie', + 'todo.hasDescription': 'Heeft beschrijving', + 'todo.addItem': 'Nieuwe taak toevoegen...', + 'todo.newCategory': 'Categorienaam', + 'todo.addCategory': 'Categorie toevoegen', + 'todo.newItem': 'Nieuwe taak', + 'todo.empty': 'Nog geen taken. Voeg een taak toe om te beginnen!', + 'todo.filter.my': 'Mijn taken', + 'todo.filter.overdue': 'Verlopen', + 'todo.sidebar.tasks': 'Taken', + 'todo.sidebar.categories': 'Categorieën', + 'todo.detail.title': 'Taak', + 'todo.detail.description': 'Beschrijving', + 'todo.detail.category': 'Categorie', + 'todo.detail.dueDate': 'Vervaldatum', + 'todo.detail.assignedTo': 'Toegewezen aan', + 'todo.detail.delete': 'Verwijderen', + 'todo.detail.save': 'Wijzigingen opslaan', + 'todo.detail.create': 'Taak aanmaken', + 'todo.detail.priority': 'Prioriteit', + 'todo.detail.noPriority': 'Geen', + 'todo.sortByPrio': 'Prioriteit', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Nieuwe versie beschikbaar', + 'settings.notificationPreferences.noChannels': 'Er zijn geen meldingskanalen geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te stellen.', + 'settings.webhookUrl.label': 'Webhook-URL', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Voer je Discord-, Slack- of aangepaste webhook-URL in om meldingen te ontvangen.', + 'settings.webhookUrl.save': 'Opslaan', + 'settings.webhookUrl.saved': 'Webhook-URL opgeslagen', + 'settings.webhookUrl.test': 'Testen', + 'settings.webhookUrl.testSuccess': 'Test-webhook succesvol verzonden', + 'settings.webhookUrl.testFailed': 'Test-webhook mislukt', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'In-app-meldingen zijn altijd actief en kunnen niet globaal worden uitgeschakeld.', + 'admin.notifications.adminWebhookPanel.title': 'Admin-webhook', + 'admin.notifications.adminWebhookPanel.hint': 'Deze webhook wordt uitsluitend gebruikt voor admin-meldingen (bijv. versie-updates). Hij staat los van gebruikerswebhooks en verstuurt automatisch als er een URL is ingesteld.', + 'admin.notifications.adminWebhookPanel.saved': 'Admin-webhook-URL opgeslagen', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Test-webhook succesvol verzonden', + 'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld', + 'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.', + 'admin.tabs.notifications': 'Meldingen', + 'admin.tabs.notificationChannels': 'Meldingskanalen', + 'admin.tabs.adminNotifications': 'Admin-meldingen', + 'notifications.versionAvailable.title': 'Update beschikbaar', + 'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.', + 'notifications.versionAvailable.button': 'Details bekijken', + 'notif.test.title': '[Test] Melding', + 'notif.test.simple.text': 'Dit is een eenvoudige testmelding.', + 'notif.test.boolean.text': 'Accepteer je deze testmelding?', + 'notif.test.navigate.text': 'Klik hieronder om naar het dashboard te gaan.', + + // Notifications + 'notif.trip_invite.title': 'Reisuitnodiging', + 'notif.trip_invite.text': '{actor} heeft je uitgenodigd voor {trip}', + 'notif.booking_change.title': 'Boeking bijgewerkt', + 'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}', + 'notif.trip_reminder.title': 'Reisherinnering', + 'notif.trip_reminder.text': 'Je reis {trip} komt eraan!', + 'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging', + 'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren', + 'notif.photos_shared.title': 'Foto\'s gedeeld', + 'notif.photos_shared.text': '{actor} heeft {count} foto(\'s) gedeeld in {trip}', + 'notif.collab_message.title': 'Nieuw bericht', + 'notif.collab_message.text': '{actor} heeft een bericht gestuurd in {trip}', + 'notif.packing_tagged.title': 'Paklijsttaak', + 'notif.packing_tagged.text': '{actor} heeft je toegewezen aan {category} in {trip}', + 'notif.version_available.title': 'Nieuwe versie beschikbaar', + 'notif.version_available.text': 'TREK {version} is nu beschikbaar', + 'notif.action.view_trip': 'Reis bekijken', + 'notif.action.view_collab': 'Berichten bekijken', + 'notif.action.view_packing': 'Paklijst bekijken', + 'notif.action.view_photos': 'Foto\'s bekijken', + 'notif.action.view_vacay': 'Vacay bekijken', + 'notif.action.view_admin': 'Naar admin', + 'notif.action.view': 'Bekijken', + 'notif.action.accept': 'Accepteren', + 'notif.action.decline': 'Weigeren', + 'notif.generic.title': 'Melding', + 'notif.generic.text': 'Je hebt een nieuwe melding', + 'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis', + 'notif.dev.unknown_event.text': 'Gebeurtenistype "{event}" is niet geregistreerd in EVENT_NOTIFICATION_CONFIG', } export default nl diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 6bbddbd..ca58b11 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -149,6 +149,7 @@ const pl: Record = { 'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)', 'settings.notifyPackingTagged': 'Lista pakowania: przypisania', 'settings.notifyWebhook': 'Powiadomienia Webhook', + 'settings.notifyVersionAvailable': 'Nowa wersja dostępna', 'admin.smtp.title': 'E-maile i powiadomienia', 'admin.smtp.hint': 'Konfiguracja SMTP dla powiadomień e-mail. Opcjonalnie: URL Webhooka dla Discorda, Slacka, itp.', 'admin.smtp.testButton': 'Wyślij testowego e-maila', @@ -349,6 +350,9 @@ const pl: Record = { 'admin.tabs.users': 'Użytkownicy', 'admin.tabs.categories': 'Kategorie', 'admin.tabs.backup': 'Backupy', + 'admin.tabs.notifications': 'Powiadomienia', + 'admin.tabs.notificationChannels': 'Kanały powiadomień', + 'admin.tabs.adminNotifications': 'Powiadomienia admina', 'admin.tabs.audit': 'Aktywność', 'admin.stats.users': 'Użytkownicy', 'admin.stats.trips': 'Podróże', @@ -455,8 +459,8 @@ const pl: Record = { 'admin.tabs.addons': 'Dodatki', 'admin.addons.title': 'Dodatki', 'admin.addons.subtitle': 'Włączaj lub wyłączaj funkcje, aby dostosować swoje doświadczenie w TREK.', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Listy', + 'admin.addons.catalog.packing.description': 'Listy pakowania i zadania do wykonania dla Twoich podróży', 'admin.addons.catalog.budget.name': 'Budżet', 'admin.addons.catalog.budget.description': 'Śledź wydatki i planuj budżet podróży', 'admin.addons.catalog.documents.name': 'Dokumenty', @@ -472,7 +476,7 @@ const pl: Record = { 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Model Context Protocol dla integracji asystenta AI', 'admin.addons.subtitleBefore': 'Włączaj lub wyłączaj funkcje, aby dostosować swoje doświadczenie w ', - 'admin.addons.subtitleAfter': '', + 'admin.addons.subtitleAfter': '.', 'admin.addons.enabled': 'Włączone', 'admin.addons.disabled': 'Wyłączone', 'admin.addons.type.trip': 'Podróż', @@ -703,8 +707,8 @@ const pl: Record = { 'trip.tabs.reservationsShort': 'Rezerwacje', 'trip.tabs.packing': 'Lista pakowania', 'trip.tabs.packingShort': 'Pakowanie', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Listy', + 'trip.tabs.listsShort': 'Listy', 'trip.tabs.budget': 'Budżet', 'trip.tabs.files': 'Pliki', 'trip.loading': 'Ładowanie podróży...', @@ -892,11 +896,11 @@ const pl: Record = { 'reservations.linkAssignment': 'Przypisz do miejsca', 'reservations.pickAssignment': 'Wybierz miejsce z planu...', 'reservations.noAssignment': 'Brak przypisania (samodzielna)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Cena', + 'reservations.budgetCategory': 'Kategoria budżetu', + 'reservations.budgetCategoryPlaceholder': 'np. Transport, Zakwaterowanie', + 'reservations.budgetCategoryAuto': 'Auto (na podstawie typu rezerwacji)', + 'reservations.budgetHint': 'Wpis budżetowy zostanie automatycznie utworzony podczas zapisywania.', 'reservations.departureDate': 'Wylot', 'reservations.arrivalDate': 'Przylot', 'reservations.departureTime': 'Godz. wylotu', @@ -1445,8 +1449,31 @@ const pl: Record = { 'admin.notifications.testWebhook': 'Wyślij testowy webhook', 'admin.notifications.testWebhookSuccess': 'Testowy webhook wysłany pomyślnie', 'admin.notifications.testWebhookFailed': 'Testowy webhook nie powiódł się', - 'admin.webhook.hint': 'Wysyłaj powiadomienia do zewnętrznego webhooka.', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'Powiadomienia w aplikacji są zawsze aktywne i nie można ich globalnie wyłączyć.', + 'admin.notifications.adminWebhookPanel.title': 'Webhook admina', + 'admin.notifications.adminWebhookPanel.hint': 'Ten webhook służy wyłącznie do powiadomień admina (np. alertów o nowych wersjach). Jest niezależny od webhooków użytkowników i wysyła automatycznie, gdy URL jest skonfigurowany.', + 'admin.notifications.adminWebhookPanel.saved': 'URL webhooka admina zapisany', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Testowy webhook wysłany pomyślnie', + 'admin.notifications.adminWebhookPanel.testFailed': 'Wysyłanie testowego webhooka nie powiodło się', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany', + 'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.', + 'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).', 'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.', + 'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.', + 'settings.webhookUrl.label': 'URL webhooka', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Wprowadź adres URL webhooka Discord, Slack lub własnego, aby otrzymywać powiadomienia.', + 'settings.webhookUrl.save': 'Zapisz', + 'settings.webhookUrl.saved': 'URL webhooka zapisany', + 'settings.webhookUrl.test': 'Test', + 'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie', + 'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', 'settings.notificationsActive': 'Aktywny kanał', 'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.', 'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.', @@ -1543,13 +1570,16 @@ const pl: Record = { 'notifications.deleteAll': 'Usuń wszystkie', 'notifications.showAll': 'Pokaż wszystkie', 'notifications.empty': 'Brak powiadomień', - 'notifications.emptyDescription': "You're all caught up!", + 'notifications.emptyDescription': 'Jesteś na bieżąco!', 'notifications.all': 'Wszystkie', 'notifications.unreadOnly': 'Nieprzeczytane', 'notifications.markRead': 'Oznacz jako przeczytane', 'notifications.markUnread': 'Oznacz jako nieprzeczytane', 'notifications.delete': 'Usuń', 'notifications.system': 'System', + 'notifications.versionAvailable.title': 'Dostępna aktualizacja', + 'notifications.versionAvailable.text': 'TREK {version} jest już dostępny.', + 'notifications.versionAvailable.button': 'Zobacz szczegóły', 'notifications.test.title': 'Testowe powiadomienie od {actor}', 'notifications.test.text': 'To jest powiadomienie testowe.', 'notifications.test.booleanTitle': '{actor} prosi o akceptację', @@ -1565,38 +1595,75 @@ const pl: Record = { 'notifications.test.tripText': 'Testowe powiadomienie dla podróży "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': 'Lista pakowania', + 'todo.subtab.todo': 'Do zrobienia', + 'todo.completed': 'ukończono', + 'todo.filter.all': 'Wszystkie', + 'todo.filter.open': 'Otwarte', + 'todo.filter.done': 'Gotowe', + 'todo.uncategorized': 'Bez kategorii', + 'todo.namePlaceholder': 'Nazwa zadania', + 'todo.descriptionPlaceholder': 'Opis (opcjonalnie)', + 'todo.unassigned': 'Nieprzypisane', + 'todo.noCategory': 'Brak kategorii', + 'todo.hasDescription': 'Ma opis', + 'todo.addItem': 'Dodaj nowe zadanie...', + 'todo.newCategory': 'Nazwa kategorii', + 'todo.addCategory': 'Dodaj kategorię', + 'todo.newItem': 'Nowe zadanie', + 'todo.empty': 'Brak zadań. Dodaj zadanie, aby zacząć!', + 'todo.filter.my': 'Moje zadania', + 'todo.filter.overdue': 'Przeterminowane', + 'todo.sidebar.tasks': 'Zadania', + 'todo.sidebar.categories': 'Kategorie', + 'todo.detail.title': 'Zadanie', + 'todo.detail.description': 'Opis', + 'todo.detail.category': 'Kategoria', + 'todo.detail.dueDate': 'Termin', + 'todo.detail.assignedTo': 'Przypisano do', + 'todo.detail.delete': 'Usuń', + 'todo.detail.save': 'Zapisz zmiany', + 'todo.detail.create': 'Utwórz zadanie', + 'todo.detail.priority': 'Priorytet', + 'todo.detail.noPriority': 'Brak', + 'todo.sortByPrio': 'Priorytet', + + // Notifications — dev test events + 'notif.test.title': '[Test] Powiadomienie', + 'notif.test.simple.text': 'To jest proste powiadomienie testowe.', + 'notif.test.boolean.text': 'Czy akceptujesz to powiadomienie testowe?', + 'notif.test.navigate.text': 'Kliknij poniżej, aby przejść do pulpitu.', + + // Notifications + 'notif.trip_invite.title': 'Zaproszenie do podróży', + 'notif.trip_invite.text': '{actor} zaprosił Cię do {trip}', + 'notif.booking_change.title': 'Rezerwacja zaktualizowana', + 'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}', + 'notif.trip_reminder.title': 'Przypomnienie o podróży', + 'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!', + 'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion', + 'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych', + 'notif.photos_shared.title': 'Zdjęcia udostępnione', + 'notif.photos_shared.text': '{actor} udostępnił {count} zdjęcie/zdjęcia w {trip}', + 'notif.collab_message.title': 'Nowa wiadomość', + 'notif.collab_message.text': '{actor} wysłał wiadomość w {trip}', + 'notif.packing_tagged.title': 'Zadanie pakowania', + 'notif.packing_tagged.text': '{actor} przypisał Cię do {category} w {trip}', + 'notif.version_available.title': 'Nowa wersja dostępna', + 'notif.version_available.text': 'TREK {version} jest teraz dostępny', + 'notif.action.view_trip': 'Zobacz podróż', + 'notif.action.view_collab': 'Zobacz wiadomości', + 'notif.action.view_packing': 'Zobacz pakowanie', + 'notif.action.view_photos': 'Zobacz zdjęcia', + 'notif.action.view_vacay': 'Zobacz Vacay', + 'notif.action.view_admin': 'Przejdź do admina', + 'notif.action.view': 'Zobacz', + 'notif.action.accept': 'Akceptuj', + 'notif.action.decline': 'Odrzuć', + 'notif.generic.title': 'Powiadomienie', + 'notif.generic.text': 'Masz nowe powiadomienie', + 'notif.dev.unknown_event.title': '[DEV] Nieznane zdarzenie', + 'notif.dev.unknown_event.text': 'Typ zdarzenia "{event}" nie jest zarejestrowany w EVENT_NOTIFICATION_CONFIG', } export default pl diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 5e6ddc6..118e8de 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -492,8 +492,8 @@ const ru: Record = { 'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Протокол контекста модели для интеграции с ИИ-ассистентами', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': 'Списки', + 'admin.addons.catalog.packing.description': 'Списки вещей и задачи для ваших поездок', 'admin.addons.catalog.budget.name': 'Бюджет', 'admin.addons.catalog.budget.description': 'Отслеживайте расходы и планируйте бюджет поездки', 'admin.addons.catalog.documents.name': 'Документы', @@ -740,8 +740,8 @@ const ru: Record = { 'trip.tabs.reservationsShort': 'Брони', 'trip.tabs.packing': 'Список вещей', 'trip.tabs.packingShort': 'Вещи', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': 'Списки', + 'trip.tabs.listsShort': 'Списки', 'trip.tabs.budget': 'Бюджет', 'trip.tabs.files': 'Файлы', 'trip.loading': 'Загрузка поездки...', @@ -936,11 +936,11 @@ const ru: Record = { 'reservations.linkAssignment': 'Привязать к назначению дня', 'reservations.pickAssignment': 'Выберите назначение из вашего плана...', 'reservations.noAssignment': 'Без привязки (самостоятельное)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': 'Цена', + 'reservations.budgetCategory': 'Категория бюджета', + 'reservations.budgetCategoryPlaceholder': 'напр. Транспорт, Проживание', + 'reservations.budgetCategoryAuto': 'Авто (по типу бронирования)', + 'reservations.budgetHint': 'При сохранении будет автоматически создана запись бюджета.', 'reservations.departureDate': 'Вылет', 'reservations.arrivalDate': 'Прилёт', 'reservations.departureTime': 'Время вылета', @@ -1572,38 +1572,105 @@ const ru: Record = { 'notifications.test.tripText': 'Тестовое уведомление для поездки "{trip}".', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': 'Список вещей', + 'todo.subtab.todo': 'Задачи', + 'todo.completed': 'выполнено', + 'todo.filter.all': 'Все', + 'todo.filter.open': 'Открытые', + 'todo.filter.done': 'Выполненные', + 'todo.uncategorized': 'Без категории', + 'todo.namePlaceholder': 'Название задачи', + 'todo.descriptionPlaceholder': 'Описание (необязательно)', + 'todo.unassigned': 'Не назначено', + 'todo.noCategory': 'Без категории', + 'todo.hasDescription': 'Есть описание', + 'todo.addItem': 'Добавить новую задачу...', + 'todo.newCategory': 'Название категории', + 'todo.addCategory': 'Добавить категорию', + 'todo.newItem': 'Новая задача', + 'todo.empty': 'Задач пока нет. Добавьте задачу, чтобы начать!', + 'todo.filter.my': 'Мои задачи', + 'todo.filter.overdue': 'Просроченные', + 'todo.sidebar.tasks': 'Задачи', + 'todo.sidebar.categories': 'Категории', + 'todo.detail.title': 'Задача', + 'todo.detail.description': 'Описание', + 'todo.detail.category': 'Категория', + 'todo.detail.dueDate': 'Срок выполнения', + 'todo.detail.assignedTo': 'Назначено', + 'todo.detail.delete': 'Удалить', + 'todo.detail.save': 'Сохранить изменения', + 'todo.detail.create': 'Создать задачу', + 'todo.detail.priority': 'Приоритет', + 'todo.detail.noPriority': 'Нет', + 'todo.sortByPrio': 'Приоритет', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': 'Доступна новая версия', + 'settings.notificationPreferences.noChannels': 'Каналы уведомлений не настроены. Попросите администратора настроить уведомления по электронной почте или через webhook.', + 'settings.webhookUrl.label': 'URL вебхука', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Введите URL вашего вебхука Discord, Slack или пользовательского для получения уведомлений.', + 'settings.webhookUrl.save': 'Сохранить', + 'settings.webhookUrl.saved': 'URL вебхука сохранён', + 'settings.webhookUrl.test': 'Тест', + 'settings.webhookUrl.testSuccess': 'Тестовый вебхук успешно отправлен', + 'settings.webhookUrl.testFailed': 'Ошибка тестового вебхука', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': 'Уведомления в приложении всегда активны и не могут быть отключены глобально.', + 'admin.notifications.adminWebhookPanel.title': 'Вебхук администратора', + 'admin.notifications.adminWebhookPanel.hint': 'Этот вебхук используется исключительно для уведомлений администратора (например, оповещения о версиях). Он независим от пользовательских вебхуков и отправляется автоматически при наличии URL.', + 'admin.notifications.adminWebhookPanel.saved': 'URL вебхука администратора сохранён', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен', + 'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL', + 'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.', + 'admin.tabs.notifications': 'Уведомления', + 'admin.tabs.notificationChannels': 'Каналы уведомлений', + 'admin.tabs.adminNotifications': 'Уведомления администратора', + 'notifications.versionAvailable.title': 'Доступно обновление', + 'notifications.versionAvailable.text': 'TREK {version} теперь доступен.', + 'notifications.versionAvailable.button': 'Подробнее', + 'notif.test.title': '[Тест] Уведомление', + 'notif.test.simple.text': 'Это простое тестовое уведомление.', + 'notif.test.boolean.text': 'Вы принимаете это тестовое уведомление?', + 'notif.test.navigate.text': 'Нажмите ниже для перехода на панель управления.', + + // Notifications + 'notif.trip_invite.title': 'Приглашение в поездку', + 'notif.trip_invite.text': '{actor} пригласил вас в {trip}', + 'notif.booking_change.title': 'Бронирование обновлено', + 'notif.booking_change.text': '{actor} обновил бронирование в {trip}', + 'notif.trip_reminder.title': 'Напоминание о поездке', + 'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!', + 'notif.vacay_invite.title': 'Приглашение Vacay Fusion', + 'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска', + 'notif.photos_shared.title': 'Фото опубликованы', + 'notif.photos_shared.text': '{actor} поделился {count} фото в {trip}', + 'notif.collab_message.title': 'Новое сообщение', + 'notif.collab_message.text': '{actor} отправил сообщение в {trip}', + 'notif.packing_tagged.title': 'Задание для упаковки', + 'notif.packing_tagged.text': '{actor} назначил вас в {category} в {trip}', + 'notif.version_available.title': 'Доступна новая версия', + 'notif.version_available.text': 'TREK {version} теперь доступен', + 'notif.action.view_trip': 'Открыть поездку', + 'notif.action.view_collab': 'Открыть сообщения', + 'notif.action.view_packing': 'Открыть упаковку', + 'notif.action.view_photos': 'Открыть фото', + 'notif.action.view_vacay': 'Открыть Vacay', + 'notif.action.view_admin': 'Перейти в админ', + 'notif.action.view': 'Открыть', + 'notif.action.accept': 'Принять', + 'notif.action.decline': 'Отклонить', + 'notif.generic.title': 'Уведомление', + 'notif.generic.text': 'У вас новое уведомление', + 'notif.dev.unknown_event.title': '[DEV] Неизвестное событие', + 'notif.dev.unknown_event.text': 'Тип события "{event}" не зарегистрирован в EVENT_NOTIFICATION_CONFIG', } export default ru diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 6de6801..1ba8d42 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -492,8 +492,8 @@ const zh: Record = { 'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': '用于 AI 助手集成的模型上下文协议', - 'admin.addons.catalog.packing.name': 'Lists', - 'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips', + 'admin.addons.catalog.packing.name': '列表', + 'admin.addons.catalog.packing.description': '行程打包清单与待办任务', 'admin.addons.catalog.budget.name': '预算', 'admin.addons.catalog.budget.description': '跟踪支出并规划旅行预算', 'admin.addons.catalog.documents.name': '文档', @@ -740,8 +740,8 @@ const zh: Record = { 'trip.tabs.reservationsShort': '预订', 'trip.tabs.packing': '行李清单', 'trip.tabs.packingShort': '行李', - 'trip.tabs.lists': 'Lists', - 'trip.tabs.listsShort': 'Lists', + 'trip.tabs.lists': '列表', + 'trip.tabs.listsShort': '列表', 'trip.tabs.budget': '预算', 'trip.tabs.files': '文件', 'trip.loading': '加载旅行中...', @@ -936,11 +936,11 @@ const zh: Record = { 'reservations.linkAssignment': '关联日程分配', 'reservations.pickAssignment': '从计划中选择一个分配...', 'reservations.noAssignment': '无关联(独立)', - 'reservations.price': 'Price', - 'reservations.budgetCategory': 'Budget category', - 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', - 'reservations.budgetCategoryAuto': 'Auto (from booking type)', - 'reservations.budgetHint': 'A budget entry will be created automatically when saving.', + 'reservations.price': '价格', + 'reservations.budgetCategory': '预算类别', + 'reservations.budgetCategoryPlaceholder': '例:交通、住宿', + 'reservations.budgetCategoryAuto': '自动(按预订类型)', + 'reservations.budgetHint': '保存时将自动创建预算条目。', 'reservations.departureDate': '出发', 'reservations.arrivalDate': '到达', 'reservations.departureTime': '出发时间', @@ -1572,38 +1572,105 @@ const zh: Record = { 'notifications.test.tripText': '行程"{trip}"的测试通知。', // Todo - 'todo.subtab.packing': 'Packing List', - 'todo.subtab.todo': 'To-Do', - 'todo.completed': 'completed', - 'todo.filter.all': 'All', - 'todo.filter.open': 'Open', - 'todo.filter.done': 'Done', - 'todo.uncategorized': 'Uncategorized', - 'todo.namePlaceholder': 'Task name', - 'todo.descriptionPlaceholder': 'Description (optional)', - 'todo.unassigned': 'Unassigned', - 'todo.noCategory': 'No category', - 'todo.hasDescription': 'Has description', - 'todo.addItem': 'Add new task...', - 'todo.newCategory': 'Category name', - 'todo.addCategory': 'Add category', - 'todo.newItem': 'New task', - 'todo.empty': 'No tasks yet. Add a task to get started!', - 'todo.filter.my': 'My Tasks', - 'todo.filter.overdue': 'Overdue', - 'todo.sidebar.tasks': 'Tasks', - 'todo.sidebar.categories': 'Categories', - 'todo.detail.title': 'Task', - 'todo.detail.description': 'Description', - 'todo.detail.category': 'Category', - 'todo.detail.dueDate': 'Due date', - 'todo.detail.assignedTo': 'Assigned to', - 'todo.detail.delete': 'Delete', - 'todo.detail.save': 'Save changes', - 'todo.detail.create': 'Create task', - 'todo.detail.priority': 'Priority', - 'todo.detail.noPriority': 'None', - 'todo.sortByPrio': 'Priority', + 'todo.subtab.packing': '行李清单', + 'todo.subtab.todo': '待办事项', + 'todo.completed': '已完成', + 'todo.filter.all': '全部', + 'todo.filter.open': '进行中', + 'todo.filter.done': '已完成', + 'todo.uncategorized': '未分类', + 'todo.namePlaceholder': '任务名称', + 'todo.descriptionPlaceholder': '描述(可选)', + 'todo.unassigned': '未分配', + 'todo.noCategory': '无分类', + 'todo.hasDescription': '有描述', + 'todo.addItem': '添加新任务...', + 'todo.newCategory': '分类名称', + 'todo.addCategory': '添加分类', + 'todo.newItem': '新任务', + 'todo.empty': '暂无任务,添加一个任务开始吧!', + 'todo.filter.my': '我的任务', + 'todo.filter.overdue': '已逾期', + 'todo.sidebar.tasks': '任务', + 'todo.sidebar.categories': '分类', + 'todo.detail.title': '任务', + 'todo.detail.description': '描述', + 'todo.detail.category': '分类', + 'todo.detail.dueDate': '截止日期', + 'todo.detail.assignedTo': '分配给', + 'todo.detail.delete': '删除', + 'todo.detail.save': '保存更改', + 'todo.detail.create': '创建任务', + 'todo.detail.priority': '优先级', + 'todo.detail.noPriority': '无', + 'todo.sortByPrio': '优先级', + + // Notification system (added from feat/notification-system) + 'settings.notifyVersionAvailable': '有新版本可用', + 'settings.notificationPreferences.noChannels': '未配置通知渠道。请联系管理员设置电子邮件或 Webhook 通知。', + 'settings.webhookUrl.label': 'Webhook URL', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': '输入您的 Discord、Slack 或自定义 Webhook URL 以接收通知。', + 'settings.webhookUrl.save': '保存', + 'settings.webhookUrl.saved': 'Webhook URL 已保存', + 'settings.webhookUrl.test': '测试', + 'settings.webhookUrl.testSuccess': '测试 Webhook 发送成功', + 'settings.webhookUrl.testFailed': '测试 Webhook 失败', + 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.email': 'Email', + 'admin.notifications.emailPanel.title': 'Email (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'In-App', + 'admin.notifications.inappPanel.hint': '应用内通知始终处于活跃状态,无法全局禁用。', + 'admin.notifications.adminWebhookPanel.title': '管理员 Webhook', + 'admin.notifications.adminWebhookPanel.hint': '此 Webhook 专用于管理员通知(如版本更新提醒)。它与用户 Webhook 相互独立,配置 URL 后自动触发。', + 'admin.notifications.adminWebhookPanel.saved': '管理员 Webhook URL 已保存', + 'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功', + 'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发', + 'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。', + 'admin.tabs.notifications': '通知', + 'admin.tabs.notificationChannels': '通知渠道', + 'admin.tabs.adminNotifications': '管理员通知', + 'notifications.versionAvailable.title': '有可用更新', + 'notifications.versionAvailable.text': 'TREK {version} 现已可用。', + 'notifications.versionAvailable.button': '查看详情', + 'notif.test.title': '[测试] 通知', + 'notif.test.simple.text': '这是一条简单的测试通知。', + 'notif.test.boolean.text': '您是否接受此测试通知?', + 'notif.test.navigate.text': '点击下方前往控制台。', + + // Notifications + 'notif.trip_invite.title': '旅行邀请', + 'notif.trip_invite.text': '{actor} 邀请您加入 {trip}', + 'notif.booking_change.title': '预订已更新', + 'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订', + 'notif.trip_reminder.title': '旅行提醒', + 'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!', + 'notif.vacay_invite.title': 'Vacay 融合邀请', + 'notif.vacay_invite.text': '{actor} 邀请您合并假期计划', + 'notif.photos_shared.title': '照片已分享', + 'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 张照片', + 'notif.collab_message.title': '新消息', + 'notif.collab_message.text': '{actor} 在 {trip} 中发送了消息', + 'notif.packing_tagged.title': '行李分配', + 'notif.packing_tagged.text': '{actor} 将您分配到 {trip} 中的 {category}', + 'notif.version_available.title': '新版本可用', + 'notif.version_available.text': 'TREK {version} 现已可用', + 'notif.action.view_trip': '查看旅行', + 'notif.action.view_collab': '查看消息', + 'notif.action.view_packing': '查看行李', + 'notif.action.view_photos': '查看照片', + 'notif.action.view_vacay': '查看 Vacay', + 'notif.action.view_admin': '前往管理', + 'notif.action.view': '查看', + 'notif.action.accept': '接受', + 'notif.action.decline': '拒绝', + 'notif.generic.title': '通知', + 'notif.generic.text': '您有一条新通知', + 'notif.dev.unknown_event.title': '[DEV] 未知事件', + 'notif.dev.unknown_event.text': '事件类型 "{event}" 未在 EVENT_NOTIFICATION_CONFIG 中注册', } export default zh diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 471338c..ae93a99 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -57,6 +57,107 @@ interface UpdateInfo { is_docker?: boolean } +const ADMIN_EVENT_LABEL_KEYS: Record = { + version_available: 'settings.notifyVersionAvailable', +} + +const ADMIN_CHANNEL_LABEL_KEYS: Record = { + inapp: 'settings.notificationPreferences.inapp', + email: 'settings.notificationPreferences.email', + webhook: 'settings.notificationPreferences.webhook', +} + +function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType }) { + const [matrix, setMatrix] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {}) + }, []) + + if (!matrix) return

Loading…

+ + const visibleChannels = (['inapp', 'email', 'webhook'] as const).filter(ch => { + if (!matrix.available_channels[ch]) return false + return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch)) + }) + + const toggle = async (eventType: string, channel: string) => { + const current = matrix.preferences[eventType]?.[channel] ?? true + const updated = { ...matrix.preferences, [eventType]: { ...matrix.preferences[eventType], [channel]: !current } } + setMatrix((m: any) => m ? { ...m, preferences: updated } : m) + setSaving(true) + try { + await adminApi.updateNotificationPreferences(updated) + } catch { + setMatrix((m: any) => m ? { ...m, preferences: matrix.preferences } : m) + toast.error(t('common.error')) + } finally { + setSaving(false) + } + } + + if (matrix.event_types.length === 0) { + return ( +
+

{t('settings.notificationPreferences.noChannels')}

+
+ ) + } + + return ( +
+
+
+

{t('admin.tabs.adminNotifications')}

+

{t('admin.notifications.adminNotificationsHint')}

+
+
+ {saving &&

Saving…

} + {/* Header row */} +
'80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> + + {visibleChannels.map(ch => ( + + {t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch} + + ))} +
+ {/* Event rows */} + {matrix.event_types.map((eventType: string) => { + const implementedForEvent = matrix.implemented_combos[eventType] ?? [] + return ( +
'80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border-primary)' }}> + + {t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType} + + {visibleChannels.map(ch => { + if (!implementedForEvent.includes(ch)) { + return + } + const isOn = matrix.preferences[eventType]?.[ch] ?? true + return ( +
+ +
+ ) + })} +
+ ) + })} +
+
+
+ ) +} + export default function AdminPage(): React.ReactElement { const { demoMode, serverTimezone } = useAuthStore() const { t, locale } = useTranslation() @@ -68,6 +169,8 @@ export default function AdminPage(): React.ReactElement { { id: 'config', label: t('admin.tabs.config') }, { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, + { id: 'notification-channels', label: t('admin.tabs.notificationChannels') }, + { id: 'admin-notifications', label: t('admin.tabs.adminNotifications') }, { id: 'backup', label: t('admin.tabs.backup') }, { id: 'audit', label: t('admin.tabs.audit') }, ...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []), @@ -969,189 +1072,6 @@ export default function AdminPage(): React.ReactElement {
- {/* Notifications — exclusive channel selector */} -
-
-

{t('admin.notifications.title')}

-

{t('admin.notifications.hint')}

-
-
- {/* Channel selector */} -
- {(['none', 'email', 'webhook'] as const).map(ch => { - const active = (smtpValues.notification_channel || 'none') === ch - const labels: Record = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') } - return ( - - ) - })} -
- - {/* Notification event toggles — shown when any channel is active */} - {(smtpValues.notification_channel || 'none') !== 'none' && (() => { - const ch = smtpValues.notification_channel || 'none' - const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false - return ( -
-

{t('admin.notifications.events')}

- {!configValid && ( -

{t('admin.notifications.configureFirst')}

- )} -

{t('admin.notifications.eventsHint')}

- {[ - { key: 'notify_trip_invite', label: t('settings.notifyTripInvite') }, - { key: 'notify_booking_change', label: t('settings.notifyBookingChange') }, - { key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') }, - { key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }, - { key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }, - { key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }, - { key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }, - ].map(opt => { - const isOn = (smtpValues[opt.key] ?? 'true') !== 'false' - return ( -
- {opt.label} - -
- ) - })} -
- ) - })()} - - {/* Email (SMTP) settings — shown when email channel is active */} - {(smtpValues.notification_channel || 'none') === 'email' && ( -
-

{t('admin.smtp.hint')}

- {smtpLoaded && [ - { key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' }, - { key: 'smtp_port', label: 'SMTP Port', placeholder: '587' }, - { key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' }, - { key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' }, - { key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' }, - ].map(field => ( -
- - setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.placeholder} - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -
- ))} -
-
- Skip TLS certificate check -

Enable for self-signed certificates on local mail servers

-
- -
-
- )} - - {/* Webhook settings — shown when webhook channel is active */} - {(smtpValues.notification_channel || 'none') === 'webhook' && ( -
-

{t('admin.webhook.hint')}

-
- - setSmtpValues(prev => ({ ...prev, notification_webhook_url: e.target.value }))} - placeholder="https://discord.com/api/webhooks/..." - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -

TREK will POST JSON with event, title, body, and timestamp to this URL.

-
-
- )} - - {/* Save + Test buttons */} -
- - {(smtpValues.notification_channel || 'none') === 'email' && ( - - )} - {(smtpValues.notification_channel || 'none') === 'webhook' && ( - - )} -
-
-
- {/* Danger Zone */}
@@ -1179,6 +1099,208 @@ export default function AdminPage(): React.ReactElement {
)} + {activeTab === 'notification-channels' && (() => { + // Derive active channels from smtpValues.notification_channels (plural) + // with fallback to notification_channel (singular) for existing installs + const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none' + const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim()) + const emailActive = activeChans.includes('email') + const webhookActive = activeChans.includes('webhook') + + const setChannels = async (email: boolean, webhook: boolean) => { + const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none' + setSmtpValues(prev => ({ ...prev, notification_channels: chans })) + try { + await authApi.updateAppSettings({ notification_channels: chans }) + } catch { + // Revert state on failure + const reverted = [emailActive && 'email', webhookActive && 'webhook'].filter(Boolean).join(',') || 'none' + setSmtpValues(prev => ({ ...prev, notification_channels: reverted })) + toast.error(t('common.error')) + } + } + + const smtpConfigured = !!(smtpValues.smtp_host?.trim()) + const saveNotifications = async () => { + // Saves credentials only — channel activation is auto-saved by the toggle + const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify'] + const payload: Record = {} + for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] } + try { + await authApi.updateAppSettings(payload) + toast.success(t('admin.notifications.saved')) + authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => { + if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) + }).catch(() => {}) + } catch { toast.error(t('common.error')) } + } + + return ( +
+ {/* Email Panel */} +
+
+
+

{t('admin.notifications.emailPanel.title')}

+

{t('admin.smtp.hint')}

+
+ +
+
+ {smtpLoaded && [ + { key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' }, + { key: 'smtp_port', label: 'SMTP Port', placeholder: '587' }, + { key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' }, + { key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' }, + { key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' }, + ].map(field => ( +
+ + setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))} + placeholder={field.placeholder} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+ ))} +
+
+ Skip TLS certificate check +

Enable for self-signed certificates on local mail servers

+
+ +
+
+
+ + +
+
+ + {/* Webhook Panel */} +
+
+
+

{t('admin.notifications.webhookPanel.title')}

+

{t('admin.webhook.hint')}

+
+ +
+
+ + {/* In-App Panel */} +
+
+
+

{t('admin.notifications.inappPanel.title')}

+

{t('admin.notifications.inappPanel.hint')}

+
+
+ +
+
+
+ + {/* Admin Webhook Panel */} +
+
+

{t('admin.notifications.adminWebhookPanel.title')}

+

{t('admin.notifications.adminWebhookPanel.hint')}

+
+
+ {smtpLoaded && ( +
+ + setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))} + placeholder="https://discord.com/api/webhooks/..." + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+ )} +
+
+ + +
+
+ +
+ ) + })()} + + {activeTab === 'admin-notifications' && } + {activeTab === 'backup' && } {activeTab === 'audit' && } diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index b092c9b..44da863 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' @@ -34,6 +34,16 @@ export default function LoginPage(): React.ReactElement { const { setLanguageLocal } = useSettingsStore() const navigate = useNavigate() + const redirectTarget = useMemo(() => { + const params = new URLSearchParams(window.location.search) + const redirect = params.get('redirect') + // Only allow relative paths starting with / to prevent open redirect attacks + if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) { + return redirect + } + return '/dashboard' + }, []) + useEffect(() => { const params = new URLSearchParams(window.location.search) @@ -99,7 +109,7 @@ export default function LoginPage(): React.ReactElement { try { await demoLogin() setShowTakeoff(true) - setTimeout(() => navigate('/dashboard'), 2600) + setTimeout(() => navigate(redirectTarget), 2600) } catch (err: unknown) { setError(err instanceof Error ? err.message : t('login.demoFailed')) } finally { @@ -128,7 +138,7 @@ export default function LoginPage(): React.ReactElement { await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword }) await loadUser({ silent: true }) setShowTakeoff(true) - setTimeout(() => navigate('/dashboard'), 2600) + setTimeout(() => navigate(redirectTarget), 2600) return } if (mode === 'login' && mfaStep) { @@ -145,7 +155,7 @@ export default function LoginPage(): React.ReactElement { return } setShowTakeoff(true) - setTimeout(() => navigate('/dashboard'), 2600) + setTimeout(() => navigate(redirectTarget), 2600) return } if (mode === 'register') { @@ -169,7 +179,7 @@ export default function LoginPage(): React.ReactElement { } } setShowTakeoff(true) - setTimeout(() => navigate('/dashboard'), 2600) + setTimeout(() => navigate(redirectTarget), 2600) } catch (err: unknown) { setError(getApiErrorMessage(err, t('login.error'))) setIsLoading(false) diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 0091947..c8e126e 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -7,7 +7,7 @@ import Navbar from '../components/Layout/Navbar' import CustomSelect from '../components/shared/CustomSelect' import { useToast } from '../components/shared/Toast' import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check, Info } from 'lucide-react' -import { authApi, adminApi } from '../api/client' +import { authApi, adminApi, notificationsApi, settingsApi } from '../api/client' import apiClient from '../api/client' import { useAddonStore } from '../store/addonStore' import type { LucideIcon } from 'lucide-react' @@ -75,37 +75,173 @@ function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { ) } +interface PreferencesMatrix { + preferences: Record> + available_channels: { email: boolean; webhook: boolean; inapp: boolean } + event_types: string[] + implemented_combos: Record +} + +const CHANNEL_LABEL_KEYS: Record = { + email: 'settings.notificationPreferences.email', + webhook: 'settings.notificationPreferences.webhook', + inapp: 'settings.notificationPreferences.inapp', +} + +const EVENT_LABEL_KEYS: Record = { + trip_invite: 'settings.notifyTripInvite', + booking_change: 'settings.notifyBookingChange', + trip_reminder: 'settings.notifyTripReminder', + vacay_invite: 'settings.notifyVacayInvite', + photos_shared: 'settings.notifyPhotosShared', + collab_message: 'settings.notifyCollabMessage', + packing_tagged: 'settings.notifyPackingTagged', + version_available: 'settings.notifyVersionAvailable', +} + function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) { - const [notifChannel, setNotifChannel] = useState('none') + const [matrix, setMatrix] = useState(null) + const [saving, setSaving] = useState(false) + const [webhookUrl, setWebhookUrl] = useState('') + const [webhookSaving, setWebhookSaving] = useState(false) + const [webhookTesting, setWebhookTesting] = useState(false) + const toast = useToast() + useEffect(() => { - authApi.getAppConfig?.().then((cfg: any) => { - if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel) + notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {}) + settingsApi.get().then((data: { settings: Record }) => { + setWebhookUrl((data.settings?.webhook_url as string) || '') }).catch(() => {}) }, []) - if (notifChannel === 'none') { + if (!matrix) return

Loading…

+ + // Which channels are both available AND have at least one implemented event + const visibleChannels = (['email', 'webhook', 'inapp'] as const).filter(ch => { + if (!matrix.available_channels[ch]) return false + return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch)) + }) + + if (visibleChannels.length === 0) { return (

- {t('settings.notificationsDisabled')} + {t('settings.notificationPreferences.noChannels')}

) } - const channelLabel = notifChannel === 'email' - ? (t('admin.notifications.email') || 'Email (SMTP)') - : (t('admin.notifications.webhook') || 'Webhook') + const toggle = async (eventType: string, channel: string) => { + const current = matrix.preferences[eventType]?.[channel] ?? true + const updated = { + ...matrix.preferences, + [eventType]: { ...matrix.preferences[eventType], [channel]: !current }, + } + setMatrix(m => m ? { ...m, preferences: updated } : m) + setSaving(true) + try { + await notificationsApi.updatePreferences(updated) + } catch { + // Revert on failure + setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m) + } finally { + setSaving(false) + } + } + + const saveWebhookUrl = async () => { + setWebhookSaving(true) + try { + await settingsApi.set('webhook_url', webhookUrl) + toast.success(t('settings.webhookUrl.saved')) + } catch { + toast.error(t('common.error')) + } finally { + setWebhookSaving(false) + } + } + + const testWebhookUrl = async () => { + if (!webhookUrl) return + setWebhookTesting(true) + try { + const result = await notificationsApi.testWebhook(webhookUrl) + if (result.success) toast.success(t('settings.webhookUrl.testSuccess')) + else toast.error(result.error || t('settings.webhookUrl.testFailed')) + } catch { + toast.error(t('settings.webhookUrl.testFailed')) + } finally { + setWebhookTesting(false) + } + } return ( -
-
- - - {t('settings.notificationsActive')}: {channelLabel} - +
+ {saving &&

Saving…

} + {/* Webhook URL configuration */} + {matrix.available_channels.webhook && ( +
+ +

{t('settings.webhookUrl.hint')}

+
+ setWebhookUrl(e.target.value)} + placeholder={t('settings.webhookUrl.placeholder')} + style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }} + /> + + +
+
+ )} + {/* Header row */} +
'64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> + + {visibleChannels.map(ch => ( + + {t(CHANNEL_LABEL_KEYS[ch]) || ch} + + ))}
-

- {t('settings.notificationsManagedByAdmin')} -

+ {/* Event rows */} + {matrix.event_types.map(eventType => { + const implementedForEvent = matrix.implemented_combos[eventType] ?? [] + const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch)) + if (relevantChannels.length === 0) return null + return ( +
'64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}> + + {t(EVENT_LABEL_KEYS[eventType]) || eventType} + + {visibleChannels.map(ch => { + if (!implementedForEvent.includes(ch)) { + return + } + const isOn = matrix.preferences[eventType]?.[ch] ?? true + return ( +
+ toggle(eventType, ch)} /> +
+ ) + })} +
+ ) + })}
) } diff --git a/server/public/index.html b/server/public/index.html index e1c0262..80b0a90 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -28,6 +28,6 @@ -
+
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f6ce2b0..f2148b5 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -577,6 +577,65 @@ function runMigrations(db: Database.Database): void { 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> = tableExists + ? db.prepare('SELECT * FROM notification_preferences').all() as Array> + : []; + const eventCols: Record = { + 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) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8506253..10508ad 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -418,6 +418,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); `); } diff --git a/server/src/index.ts b/server/src/index.ts index faef0eb..a51dde6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -46,6 +46,7 @@ const server = app.listen(PORT, () => { } scheduler.start(); scheduler.startTripReminders(); + scheduler.startVersionCheck(); scheduler.startDemoReset(); const { startTokenCleanup } = require('./services/ephemeralTokens'); startTokenCleanup(); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 56a9136..56cfa46 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -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 = { - 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 }); } diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts index 9ce3da2..2a2df8b 100644 --- a/server/src/routes/collab.ts +++ b/server/src/routes/collab.ts @@ -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(() => {}); }); }); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 8cc93b7..efdd2b9 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -114,9 +114,9 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) // Notify trip members about shared photos if (shared && added > 0) { - 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, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {}); + send({ event: 'photos_shared', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added), tripId: String(tripId) } }).catch(() => {}); }); } }); diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts index 0d7168f..e2a7847 100644 --- a/server/src/routes/notifications.ts +++ b/server/src/routes/notifications.ts @@ -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) => { @@ -38,9 +35,10 @@ 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()); + const { url } = req.body; + if (!url || typeof url !== 'string') return res.status(400).json({ error: 'url is required' }); + try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); } + res.json(await testWebhook(url)); }); // ── In-app notifications ────────────────────────────────────────────────────── diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts index 1191eaa..019f266 100644 --- a/server/src/routes/packing.ts +++ b/server/src/routes/packing.ts @@ -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(() => {}); }); } }); diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts index b606811..59be95f 100644 --- a/server/src/routes/reservations.ts +++ b/server/src/routes/reservations.ts @@ -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(() => {}); }); }); diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index c0f8267..b9225b7 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -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 }); diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index 1699aa1..8385aaa 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -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 }; diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 3762c0d..28d2f90 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -9,6 +9,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions'; import { revokeUserSessions } from '../mcp'; import { validatePassword } from './passwordPolicy'; +import { send as sendNotification } from './notificationService'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -312,6 +313,28 @@ export async function checkVersion() { } } +export async function checkAndNotifyVersion(): Promise { + 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() { diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index c2e2dd1..75495c4 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -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', @@ -714,6 +716,7 @@ export function updateAppSettings( } if (key === 'smtp_pass' && val === '••••••••') continue; if (key === 'smtp_pass') val = encrypt_api_key(val); + 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 +725,9 @@ export function updateAppSettings( const summary: Record = {}; 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 +737,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(); diff --git a/server/src/services/inAppNotifications.ts b/server/src/services/inAppNotifications.ts index 25d779f..6564915 100644 --- a/server/src/services/inAppNotifications.ts +++ b/server/src/services/inAppNotifications.ts @@ -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; 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 }; diff --git a/server/src/services/notificationPreferencesService.ts b/server/src/services/notificationPreferencesService.ts index ebd810f..f8380fd 100644 --- a/server/src/services/notificationPreferencesService.ts +++ b/server/src/services/notificationPreferencesService.ts @@ -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 = { + 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(['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>>>; + available_channels: AvailableChannels; + event_types: NotifEventType[]; + implemented_combos: Record; +} + +/** + * 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>>> = {}; + 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>>> = {}; + 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>>>, + upsert: ReturnType, + del: ReturnType +): 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>>> +): 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>>> +): 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>>> = {}; + const userPrefs: Partial>>> = {}; + + 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'); } diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts new file mode 100644 index 0000000..6fdc1ee --- /dev/null +++ b/server/src/services/notificationService.ts @@ -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 | null; +} + +const EVENT_NOTIFICATION_CONFIG: Record = { + // ── 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; + 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 }; + negativeCallback?: { action: string; payload: Record }; + navigateTarget?: string; // override the auto-generated navigate target + }; +} + +export async function send(payload: NotificationPayload): Promise { + 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[] = []; + + // ── 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}`); + }); + } + } +} diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 588d726..c4f0a16 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -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; -} +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, '''); +} + // ── 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 = { - 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 = { interface EventText { title: string; body: string } type EventTextFn = (params: Record) => EventText -const EVENT_TEXTS: Record> = { +const EVENT_TEXTS: Record> = { 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> = { 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> = { 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> = { 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> = { 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> = { 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> = { 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> = { 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> = { 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): EventText { +export function getEventText(lang: string, event: NotifEventType, params: Record): 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 ` @@ -202,9 +259,9 @@ export function buildEmailHtml(subject: string, body: string, lang: string): str -

${subject}

+

${safeSubject}

-

${body}

+

${safeBody}

${appUrl ? ` @@ -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 { +export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise { 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 { - const url = getWebhookUrl(); +export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise { 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 { - 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): Promise { - 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' }; } } + diff --git a/server/src/services/settingsService.ts b/server/src/services/settingsService.ts index 7aa49d8..2fb5527 100644 --- a/server/src/services/settingsService.ts +++ b/server/src/services/settingsService.ts @@ -1,4 +1,7 @@ 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 { const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[]; @@ -13,12 +16,17 @@ export function getUserSettings(userId: number): Record { 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) { @@ -29,8 +37,7 @@ export function bulkUpsertSettings(userId: number, settings: Record { - 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 {}; diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index 360467a..142080c 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -480,3 +480,29 @@ export function createInviteToken( ).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy); return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken; } + +// --------------------------------------------------------------------------- +// Notification helpers +// --------------------------------------------------------------------------- + +/** Upsert a key/value pair into app_settings. */ +export function setAppSetting(db: Database.Database, key: string, value: string): void { + db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value); +} + +/** Set the active notification channels (e.g. 'email', 'webhook', 'email,webhook', 'none'). */ +export function setNotificationChannels(db: Database.Database, channels: string): void { + setAppSetting(db, 'notification_channels', channels); +} + +/** Explicitly disable a per-user notification preference for a given event+channel combo. */ +export function disableNotificationPref( + db: Database.Database, + userId: number, + eventType: string, + channel: string +): void { + db.prepare( + 'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)' + ).run(userId, eventType, channel); +} diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index bae53a8..436e190 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -56,6 +56,7 @@ const RESET_TABLES = [ 'vacay_plans', 'atlas_visited_countries', 'atlas_bucket_list', + 'notification_channel_preferences', 'notifications', 'audit_log', 'user_settings', diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts index e46923c..cb37aba 100644 --- a/server/tests/integration/notifications.test.ts +++ b/server/tests/integration/notifications.test.ts @@ -39,12 +39,13 @@ vi.mock('../../src/config', () => ({ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); +vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() })); import { createApp } from '../../src/app'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; -import { createUser } from '../helpers/factories'; +import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; @@ -154,6 +155,137 @@ describe('In-app notifications', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// New preferences matrix API (NROUTE series) +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/notifications/preferences — matrix format', () => { + it('NROUTE-002 — returns preferences, available_channels, event_types, implemented_combos', async () => { + const { user } = createUser(testDb); + const res = await request(app) + .get('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('preferences'); + expect(res.body).toHaveProperty('available_channels'); + expect(res.body).toHaveProperty('event_types'); + expect(res.body).toHaveProperty('implemented_combos'); + expect(res.body.available_channels.inapp).toBe(true); + }); + + it('NROUTE-003 — regular user does not see version_available in event_types', async () => { + const { user } = createUser(testDb); + const res = await request(app) + .get('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + expect(res.body.event_types).not.toContain('version_available'); + }); + + it('NROUTE-004 — user preferences endpoint excludes version_available even for admins', async () => { + const { user } = createAdmin(testDb); + const res = await request(app) + .get('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + expect(res.body.event_types).not.toContain('version_available'); + }); + + it('NROUTE-004b — admin notification preferences endpoint returns version_available', async () => { + const { user } = createAdmin(testDb); + const res = await request(app) + .get('/api/admin/notification-preferences') + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + expect(res.body.event_types).toContain('version_available'); + }); + + it('NROUTE-005 — all preferences default to true for new user with no stored prefs', async () => { + const { user } = createUser(testDb); + const res = await request(app) + .get('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + const { preferences } = res.body; + for (const [, channels] of Object.entries(preferences)) { + for (const [, enabled] of Object.entries(channels as Record)) { + expect(enabled).toBe(true); + } + } + }); +}); + +describe('PUT /api/notifications/preferences — matrix format', () => { + it('NROUTE-007 — disabling a preference persists and is reflected in subsequent GET', async () => { + const { user } = createUser(testDb); + + const putRes = await request(app) + .put('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)) + .send({ trip_invite: { email: false } }); + + expect(putRes.status).toBe(200); + expect(putRes.body.preferences['trip_invite']['email']).toBe(false); + + const getRes = await request(app) + .get('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)); + expect(getRes.body.preferences['trip_invite']['email']).toBe(false); + }); + + it('NROUTE-008 — re-enabling a preference restores default state', async () => { + const { user } = createUser(testDb); + disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); + + const res = await request(app) + .put('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)) + .send({ trip_invite: { email: true } }); + + expect(res.status).toBe(200); + expect(res.body.preferences['trip_invite']['email']).toBe(true); + + const row = testDb.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(user.id, 'trip_invite', 'email'); + expect(row).toBeUndefined(); + }); + + it('NROUTE-009 — partial update does not affect other preferences', async () => { + const { user } = createUser(testDb); + disableNotificationPref(testDb, user.id, 'booking_change', 'email'); + + await request(app) + .put('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)) + .send({ trip_invite: { email: false } }); + + const getRes = await request(app) + .get('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)); + expect(getRes.body.preferences['booking_change']['email']).toBe(false); + expect(getRes.body.preferences['trip_invite']['email']).toBe(false); + expect(getRes.body.preferences['trip_reminder']['email']).toBe(true); + }); +}); + +describe('implemented_combos — in-app channel coverage', () => { + it('NROUTE-010 — implemented_combos includes inapp for all event types', async () => { + const { user } = createUser(testDb); + const res = await request(app) + .get('/api/notifications/preferences') + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + const { implemented_combos } = res.body as { implemented_combos: Record }; + const eventTypes = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged']; + for (const event of eventTypes) { + expect(implemented_combos[event], `${event} should support inapp`).toContain('inapp'); + expect(implemented_combos[event], `${event} should support email`).toContain('email'); + expect(implemented_combos[event], `${event} should support webhook`).toContain('webhook'); + } + }); +}); + describe('Notification test endpoints', () => { it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => { const { user } = createUser(testDb); @@ -165,13 +297,244 @@ describe('Notification test endpoints', () => { expect(res.status).toBe(403); }); - it('NOTIF-006 — POST /api/notifications/test-webhook requires admin', async () => { + it('NOTIF-006 — POST /api/notifications/test-webhook returns 400 when url is missing', async () => { const { user } = createUser(testDb); const res = await request(app) .post('/api/notifications/test-webhook') .set('Cookie', authCookie(user.id)) .send({}); + expect(res.status).toBe(400); + }); + + it('NOTIF-006b — POST /api/notifications/test-webhook returns 400 for invalid URL', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/notifications/test-webhook') + .set('Cookie', authCookie(user.id)) + .send({ url: 'not-a-url' }); + expect(res.status).toBe(400); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Helper: insert a boolean notification directly into the DB +// ───────────────────────────────────────────────────────────────────────────── + +function insertBooleanNotification(recipientId: number): number { + const result = testDb.prepare(` + INSERT INTO notifications ( + type, scope, target, sender_id, recipient_id, + title_key, title_params, text_key, text_params, + positive_text_key, negative_text_key, positive_callback, negative_callback + ) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}', + 'notif.action.accept', 'notif.action.decline', + '{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}' + ) + `).run(recipientId, recipientId); + return result.lastInsertRowid as number; +} + +function insertSimpleNotification(recipientId: number): number { + const result = testDb.prepare(` + INSERT INTO notifications ( + type, scope, target, sender_id, recipient_id, + title_key, title_params, text_key, text_params + ) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}') + `).run(recipientId, recipientId); + return result.lastInsertRowid as number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// POST /in-app/:id/respond +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/notifications/in-app/:id/respond', () => { + it('NROUTE-011 — valid positive response returns success and updated notification', async () => { + const { user } = createUser(testDb); + const id = insertBooleanNotification(user.id); + + const res = await request(app) + .post(`/api/notifications/in-app/${id}/respond`) + .set('Cookie', authCookie(user.id)) + .send({ response: 'positive' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.notification).toBeDefined(); + expect(res.body.notification.response).toBe('positive'); + }); + + it('NROUTE-012 — invalid response value returns 400', async () => { + const { user } = createUser(testDb); + const id = insertBooleanNotification(user.id); + + const res = await request(app) + .post(`/api/notifications/in-app/${id}/respond`) + .set('Cookie', authCookie(user.id)) + .send({ response: 'maybe' }); + + expect(res.status).toBe(400); + }); + + it('NROUTE-013 — response on non-existent notification returns 400', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/notifications/in-app/99999/respond') + .set('Cookie', authCookie(user.id)) + .send({ response: 'positive' }); + + expect(res.status).toBe(400); + }); + + it('NROUTE-014 — double response returns 400', async () => { + const { user } = createUser(testDb); + const id = insertBooleanNotification(user.id); + + await request(app) + .post(`/api/notifications/in-app/${id}/respond`) + .set('Cookie', authCookie(user.id)) + .send({ response: 'positive' }); + + const res = await request(app) + .post(`/api/notifications/in-app/${id}/respond`) + .set('Cookie', authCookie(user.id)) + .send({ response: 'negative' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/already responded/i); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/admin/notification-preferences +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/admin/notification-preferences', () => { + it('NROUTE-015 — admin can disable email for version_available, persists in GET', async () => { + const { user } = createAdmin(testDb); + + const putRes = await request(app) + .put('/api/admin/notification-preferences') + .set('Cookie', authCookie(user.id)) + .send({ version_available: { email: false } }); + + expect(putRes.status).toBe(200); + expect(putRes.body.preferences['version_available']['email']).toBe(false); + + const getRes = await request(app) + .get('/api/admin/notification-preferences') + .set('Cookie', authCookie(user.id)); + expect(getRes.status).toBe(200); + expect(getRes.body.preferences['version_available']['email']).toBe(false); + }); + + it('NROUTE-016 — non-admin is rejected with 403', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .put('/api/admin/notification-preferences') + .set('Cookie', authCookie(user.id)) + .send({ version_available: { email: false } }); + expect(res.status).toBe(403); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// In-app CRUD with actual notification data +// ───────────────────────────────────────────────────────────────────────────── + +describe('In-app notifications — CRUD with data', () => { + it('NROUTE-017 — GET /in-app returns created notifications', async () => { + const { user } = createUser(testDb); + insertSimpleNotification(user.id); + insertSimpleNotification(user.id); + + const res = await request(app) + .get('/api/notifications/in-app') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.notifications.length).toBe(2); + expect(res.body.total).toBe(2); + expect(res.body.unread_count).toBe(2); + }); + + it('NROUTE-018 — unread count reflects actual unread notifications', async () => { + const { user } = createUser(testDb); + insertSimpleNotification(user.id); + insertSimpleNotification(user.id); + + const res = await request(app) + .get('/api/notifications/in-app/unread-count') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + }); + + it('NROUTE-019 — mark-read on existing notification succeeds and decrements unread count', async () => { + const { user } = createUser(testDb); + const id = insertSimpleNotification(user.id); + + const markRes = await request(app) + .put(`/api/notifications/in-app/${id}/read`) + .set('Cookie', authCookie(user.id)); + expect(markRes.status).toBe(200); + expect(markRes.body.success).toBe(true); + + const countRes = await request(app) + .get('/api/notifications/in-app/unread-count') + .set('Cookie', authCookie(user.id)); + expect(countRes.body.count).toBe(0); + }); + + it('NROUTE-020 — mark-unread on a read notification succeeds', async () => { + const { user } = createUser(testDb); + const id = insertSimpleNotification(user.id); + // Mark read first + testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id); + + const res = await request(app) + .put(`/api/notifications/in-app/${id}/unread`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(id) as { is_read: number }; + expect(row.is_read).toBe(0); + }); + + it('NROUTE-021 — DELETE on existing notification removes it', async () => { + const { user } = createUser(testDb); + const id = insertSimpleNotification(user.id); + + const res = await request(app) + .delete(`/api/notifications/in-app/${id}`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + const row = testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(id); + expect(row).toBeUndefined(); + }); + + it('NROUTE-022 — unread_only=true filter returns only unread notifications', async () => { + const { user } = createUser(testDb); + const id1 = insertSimpleNotification(user.id); + insertSimpleNotification(user.id); + // Mark first one read + testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id1); + + const res = await request(app) + .get('/api/notifications/in-app?unread_only=true') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.notifications.length).toBe(1); + expect(res.body.notifications[0].is_read).toBe(0); + }); +}); diff --git a/server/tests/unit/services/inAppNotificationPrefs.test.ts b/server/tests/unit/services/inAppNotificationPrefs.test.ts new file mode 100644 index 0000000..e713eca --- /dev/null +++ b/server/tests/unit/services/inAppNotificationPrefs.test.ts @@ -0,0 +1,294 @@ +/** + * Unit tests for in-app notification preference filtering in createNotification(). + * Covers INOTIF-001 to INOTIF-004. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +// Mock WebSocket broadcast — must use vi.hoisted() so broadcastMock is available +// when the vi.mock factory is evaluated (factories are hoisted before const declarations) +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories'; +import { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); +}); + +afterAll(() => { + testDb.close(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// createNotification — preference filtering +// ───────────────────────────────────────────────────────────────────────────── + +describe('createNotification — preference filtering', () => { + it('INOTIF-001 — notification without event_type is delivered to all recipients (backward compat)', () => { + const { user: admin } = createAdmin(testDb); + const { user: recipient } = createUser(testDb); + // The admin scope targets all admins — create a second admin as the sender + const { user: sender } = createAdmin(testDb); + + // Send to a specific user (user scope) without event_type + const ids = createNotification({ + type: 'simple', + scope: 'user', + target: recipient.id, + sender_id: sender.id, + title_key: 'notifications.test.title', + text_key: 'notifications.test.text', + // no event_type + }); + + expect(ids.length).toBe(1); + const row = testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ?').get(recipient.id); + expect(row).toBeDefined(); + // Also verify the admin who disabled all prefs still gets messages without event_type + disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp'); + // admin still gets this since no event_type check + const adminIds = createNotification({ + type: 'simple', + scope: 'user', + target: admin.id, + sender_id: sender.id, + title_key: 'notifications.test.title', + text_key: 'notifications.test.text', + }); + expect(adminIds.length).toBe(1); + }); + + it('INOTIF-002 — notification with event_type skips recipients who have disabled that event on inapp', () => { + const { user: sender } = createAdmin(testDb); + const { user: recipient1 } = createUser(testDb); + const { user: recipient2 } = createUser(testDb); + + // recipient2 has disabled inapp for trip_invite + disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp'); + + // Use a trip to target both members + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number; + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id); + + const ids = createNotification({ + type: 'simple', + scope: 'trip', + target: tripId, + sender_id: sender.id, + event_type: 'trip_invite', + title_key: 'notifications.test.title', + text_key: 'notifications.test.text', + }); + + // sender excluded, recipient1 included, recipient2 skipped (disabled pref) + expect(ids.length).toBe(1); + const r1 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient1.id); + const r2 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient2.id); + expect(r1).toBeDefined(); + expect(r2).toBeUndefined(); + }); + + it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', () => { + const { user: sender } = createAdmin(testDb); + const { user: recipient } = createUser(testDb); + + // No preferences stored for recipient — should default to enabled + const ids = createNotification({ + type: 'simple', + scope: 'user', + target: recipient.id, + sender_id: sender.id, + event_type: 'trip_invite', + title_key: 'notifications.test.title', + text_key: 'notifications.test.text', + }); + + expect(ids.length).toBe(1); + const row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient.id); + expect(row).toBeDefined(); + }); + + it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', () => { + const { user: sender } = createAdmin(testDb); + const { user: recipient } = createUser(testDb); + + const id = createNotificationForRecipient( + { + type: 'navigate', + scope: 'user', + target: recipient.id, + sender_id: sender.id, + event_type: 'trip_invite', + title_key: 'notif.trip_invite.title', + text_key: 'notif.trip_invite.text', + navigate_text_key: 'notif.action.view_trip', + navigate_target: '/trips/99', + }, + recipient.id, + { username: 'admin', avatar: null } + ); + + expect(id).toBeTypeOf('number'); + const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined; + expect(row).toBeDefined(); + expect(row!.recipient_id).toBe(recipient.id); + expect(row!.navigate_target).toBe('/trips/99'); + expect(broadcastMock).toHaveBeenCalledTimes(1); + expect(broadcastMock.mock.calls[0][0]).toBe(recipient.id); + }); + + it('INOTIF-004 — admin-scope version_available only reaches admins with enabled pref', () => { + const { user: admin1 } = createAdmin(testDb); + const { user: admin2 } = createAdmin(testDb); + + // admin2 disables version_available inapp notifications + disableNotificationPref(testDb, admin2.id, 'version_available', 'inapp'); + + const ids = createNotification({ + type: 'navigate', + scope: 'admin', + target: 0, + sender_id: null, + event_type: 'version_available', + title_key: 'notifications.versionAvailable.title', + text_key: 'notifications.versionAvailable.text', + navigate_text_key: 'notifications.versionAvailable.button', + navigate_target: '/admin', + }); + + // Only admin1 should receive it + expect(ids.length).toBe(1); + const admin1Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin1.id); + const admin2Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin2.id); + expect(admin1Row).toBeDefined(); + expect(admin2Row).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// respondToBoolean +// ───────────────────────────────────────────────────────────────────────────── + +function insertBooleanNotification(recipientId: number, senderId: number | null = null): number { + const result = testDb.prepare(` + INSERT INTO notifications ( + type, scope, target, sender_id, recipient_id, + title_key, title_params, text_key, text_params, + positive_text_key, negative_text_key, positive_callback, negative_callback + ) VALUES ('boolean', 'user', ?, ?, ?, 'notif.test.title', '{}', 'notif.test.text', '{}', + 'notif.action.accept', 'notif.action.decline', + '{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}' + ) + `).run(recipientId, senderId, recipientId); + return result.lastInsertRowid as number; +} + +function insertSimpleNotification(recipientId: number): number { + const result = testDb.prepare(` + INSERT INTO notifications ( + type, scope, target, sender_id, recipient_id, + title_key, title_params, text_key, text_params + ) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}') + `).run(recipientId, recipientId); + return result.lastInsertRowid as number; +} + +describe('respondToBoolean', () => { + it('INOTIF-005 — positive response sets response=positive, marks read, broadcasts update', async () => { + const { user } = createUser(testDb); + const id = insertBooleanNotification(user.id); + + const result = await respondToBoolean(id, user.id, 'positive'); + + expect(result.success).toBe(true); + expect(result.notification).toBeDefined(); + const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as any; + expect(row.response).toBe('positive'); + expect(row.is_read).toBe(1); + expect(broadcastMock).toHaveBeenCalledWith(user.id, expect.objectContaining({ type: 'notification:updated' })); + }); + + it('INOTIF-006 — negative response sets response=negative', async () => { + const { user } = createUser(testDb); + const id = insertBooleanNotification(user.id); + + const result = await respondToBoolean(id, user.id, 'negative'); + + expect(result.success).toBe(true); + const row = testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(id) as any; + expect(row.response).toBe('negative'); + }); + + it('INOTIF-007 — double-response prevention returns error on second call', async () => { + const { user } = createUser(testDb); + const id = insertBooleanNotification(user.id); + + await respondToBoolean(id, user.id, 'positive'); + const result = await respondToBoolean(id, user.id, 'negative'); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/already responded/i); + }); + + it('INOTIF-008 — response on a simple notification returns error', async () => { + const { user } = createUser(testDb); + const id = insertSimpleNotification(user.id); + + const result = await respondToBoolean(id, user.id, 'positive'); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/not a boolean/i); + }); + + it('INOTIF-009 — response on a non-existent notification returns error', async () => { + const { user } = createUser(testDb); + const result = await respondToBoolean(99999, user.id, 'positive'); + expect(result.success).toBe(false); + expect(result.error).toMatch(/not found/i); + }); + + it('INOTIF-010 — response on notification belonging to another user returns error', async () => { + const { user: owner } = createUser(testDb); + const { user: other } = createUser(testDb); + const id = insertBooleanNotification(owner.id); + + const result = await respondToBoolean(id, other.id, 'positive'); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/not found/i); + }); +}); diff --git a/server/tests/unit/services/migration.test.ts b/server/tests/unit/services/migration.test.ts new file mode 100644 index 0000000..053101a --- /dev/null +++ b/server/tests/unit/services/migration.test.ts @@ -0,0 +1,234 @@ +/** + * Unit tests for migration 69 (normalized notification preferences). + * Covers MIGR-001 to MIGR-004. + */ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import Database from 'better-sqlite3'; +import { createTables } from '../../../src/db/schema'; + +function buildFreshDb() { + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + return db; +} + +/** + * Run all migrations up to (but NOT including) migration 69, then return the db. + * This allows us to set up old-schema data and test that migration 69 handles it. + * + * We do this by running only the schema tables that existed before migration 69, + * seeding old data, then running migration 69 in isolation. + */ +function setupPreMigration69Db() { + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + + // Create schema_version and users table (bare minimum) + db.exec(` + CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0); + INSERT INTO schema_version (version) VALUES (0); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + email TEXT, + role TEXT NOT NULL DEFAULT 'user' + ); + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + + CREATE TABLE IF NOT EXISTS notification_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notify_trip_invite INTEGER DEFAULT 1, + notify_booking_change INTEGER DEFAULT 1, + notify_trip_reminder INTEGER DEFAULT 1, + notify_vacay_invite INTEGER DEFAULT 1, + notify_photos_shared INTEGER DEFAULT 1, + notify_collab_message INTEGER DEFAULT 1, + notify_packing_tagged INTEGER DEFAULT 1, + notify_webhook INTEGER DEFAULT 1, + UNIQUE(user_id) + ); + `); + + return db; +} + +/** + * Extract and run only migration 69 (index 68) from the migrations array. + * We do this by importing migrations and calling the last one directly. + */ +function runMigration69(db: ReturnType): void { + // Migration 69 logic extracted inline for isolation + db.exec(` + CREATE TABLE IF NOT EXISTS notification_channel_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, event_type, channel) + ); + CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id); + `); + + const oldPrefs = db.prepare('SELECT * FROM notification_preferences').all() as Array>; + const eventCols: Record = { + trip_invite: 'notify_trip_invite', + booking_change: 'notify_booking_change', + trip_reminder: 'notify_trip_reminder', + vacay_invite: 'notify_vacay_invite', + photos_shared: 'notify_photos_shared', + collab_message: 'notify_collab_message', + packing_tagged: 'notify_packing_tagged', + }; + const insert = db.prepare( + 'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)' + ); + const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => { + for (const [userId, eventType, channel, enabled] of rows) { + insert.run(userId, eventType, channel, enabled); + } + }); + + for (const row of oldPrefs) { + const userId = row.user_id as number; + const webhookEnabled = (row.notify_webhook as number) ?? 0; + const rows: Array<[number, string, string, number]> = []; + for (const [eventType, col] of Object.entries(eventCols)) { + const emailEnabled = (row[col] as number) ?? 1; + if (!emailEnabled) rows.push([userId, eventType, 'email', 0]); + if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]); + } + if (rows.length > 0) insertMany(rows); + } + + db.exec(` + INSERT OR IGNORE INTO app_settings (key, value) + SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel'; + `); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Migration 69 tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('Migration 69 — normalized notification_channel_preferences', () => { + it('MIGR-001 — notification_channel_preferences table exists after migration', () => { + const db = setupPreMigration69Db(); + runMigration69(db); + + const table = db.prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'` + ).get(); + expect(table).toBeDefined(); + db.close(); + }); + + it('MIGR-002 — old notification_preferences rows with disabled events migrated as enabled=0', () => { + const db = setupPreMigration69Db(); + + // Create a user + const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('testuser', 'hash', 'user')).lastInsertRowid as number; + + // Simulate user who has disabled trip_invite and booking_change email + db.prepare(` + INSERT INTO notification_preferences + (user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder, + notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook) + VALUES (?, 0, 0, 1, 1, 1, 1, 1, 1) + `).run(userId); + + runMigration69(db); + + const tripInviteEmail = db.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(userId, 'trip_invite', 'email') as { enabled: number } | undefined; + const bookingEmail = db.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(userId, 'booking_change', 'email') as { enabled: number } | undefined; + const reminderEmail = db.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined; + + // Disabled events should have enabled=0 rows + expect(tripInviteEmail).toBeDefined(); + expect(tripInviteEmail!.enabled).toBe(0); + expect(bookingEmail).toBeDefined(); + expect(bookingEmail!.enabled).toBe(0); + // Enabled events should have no row (no-row = enabled) + expect(reminderEmail).toBeUndefined(); + + db.close(); + }); + + it('MIGR-003 — old notify_webhook=0 creates disabled webhook rows for all 7 events', () => { + const db = setupPreMigration69Db(); + + const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('webhookuser', 'hash', 'user')).lastInsertRowid as number; + + // User has all email enabled but webhook disabled + db.prepare(` + INSERT INTO notification_preferences + (user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder, + notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook) + VALUES (?, 1, 1, 1, 1, 1, 1, 1, 0) + `).run(userId); + + runMigration69(db); + + const allEvents = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged']; + for (const eventType of allEvents) { + const row = db.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(userId, eventType, 'webhook') as { enabled: number } | undefined; + expect(row).toBeDefined(); + expect(row!.enabled).toBe(0); + + // Email rows should NOT exist (all email was enabled → no row needed) + const emailRow = db.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(userId, eventType, 'email'); + expect(emailRow).toBeUndefined(); + } + + db.close(); + }); + + it('MIGR-004 — notification_channels key is created in app_settings from notification_channel value', () => { + const db = setupPreMigration69Db(); + + // Simulate existing single-channel setting + db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email'); + + runMigration69(db); + + const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined; + expect(plural).toBeDefined(); + expect(plural!.value).toBe('email'); + + db.close(); + }); + + it('MIGR-004b — notification_channels is not duplicated if already exists', () => { + const db = setupPreMigration69Db(); + + // Both keys already set (e.g. partial migration or manual edit) + db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email'); + db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channels', 'email,webhook'); + + runMigration69(db); + + // The existing notification_channels value should be preserved (INSERT OR IGNORE) + const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined; + expect(plural!.value).toBe('email,webhook'); + + db.close(); + }); +}); diff --git a/server/tests/unit/services/notificationPreferencesService.test.ts b/server/tests/unit/services/notificationPreferencesService.test.ts new file mode 100644 index 0000000..2cc09c1 --- /dev/null +++ b/server/tests/unit/services/notificationPreferencesService.test.ts @@ -0,0 +1,318 @@ +/** + * Unit tests for notificationPreferencesService. + * Covers NPREF-001 to NPREF-021. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/services/apiKeyCrypto', () => ({ + decrypt_api_key: (v: string | null) => v, + maybe_encrypt_api_key: (v: string) => v, + encrypt_api_key: (v: string) => v, +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories'; +import { + isEnabledForEvent, + getPreferencesMatrix, + setPreferences, + setAdminPreferences, + getAdminGlobalPref, + getActiveChannels, + getAvailableChannels, +} from '../../../src/services/notificationPreferencesService'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); +}); + +afterAll(() => { + testDb.close(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// isEnabledForEvent +// ───────────────────────────────────────────────────────────────────────────── + +describe('isEnabledForEvent', () => { + it('NPREF-001 — returns true when no row exists (default enabled)', () => { + const { user } = createUser(testDb); + expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true); + }); + + it('NPREF-002 — returns true when row exists with enabled=1', () => { + const { user } = createUser(testDb); + testDb.prepare( + 'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)' + ).run(user.id, 'trip_invite', 'email'); + expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true); + }); + + it('NPREF-003 — returns false when row exists with enabled=0', () => { + const { user } = createUser(testDb); + disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); + expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// getPreferencesMatrix +// ───────────────────────────────────────────────────────────────────────────── + +describe('getPreferencesMatrix', () => { + it('NPREF-004 — regular user does not see version_available in event_types', () => { + const { user } = createUser(testDb); + const { event_types } = getPreferencesMatrix(user.id, 'user'); + expect(event_types).not.toContain('version_available'); + expect(event_types.length).toBe(7); + }); + + it('NPREF-005 — user scope excludes version_available for everyone including admins', () => { + const { user } = createAdmin(testDb); + const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user'); + expect(event_types).not.toContain('version_available'); + expect(event_types.length).toBe(7); + }); + + it('NPREF-005b — admin scope returns only version_available', () => { + const { user } = createAdmin(testDb); + const { event_types } = getPreferencesMatrix(user.id, 'admin', 'admin'); + expect(event_types).toContain('version_available'); + expect(event_types.length).toBe(1); + }); + + it('NPREF-006 — returns default true for all preferences when no stored prefs', () => { + const { user } = createUser(testDb); + const { preferences } = getPreferencesMatrix(user.id, 'user'); + for (const [, channels] of Object.entries(preferences)) { + for (const [, enabled] of Object.entries(channels as Record)) { + expect(enabled).toBe(true); + } + } + }); + + it('NPREF-007 — reflects stored disabled preferences in the matrix', () => { + const { user } = createUser(testDb); + disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); + disableNotificationPref(testDb, user.id, 'collab_message', 'webhook'); + const { preferences } = getPreferencesMatrix(user.id, 'user'); + expect(preferences['trip_invite']!['email']).toBe(false); + expect(preferences['collab_message']!['webhook']).toBe(false); + // Others unaffected + expect(preferences['trip_invite']!['webhook']).toBe(true); + expect(preferences['booking_change']!['email']).toBe(true); + }); + + it('NPREF-008 — available_channels.inapp is always true', () => { + const { user } = createUser(testDb); + const { available_channels } = getPreferencesMatrix(user.id, 'user'); + expect(available_channels.inapp).toBe(true); + }); + + it('NPREF-009 — available_channels.email is true when email is in notification_channels', () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'email'); + const { available_channels } = getPreferencesMatrix(user.id, 'user'); + expect(available_channels.email).toBe(true); + }); + + it('NPREF-010 — available_channels.email is false when email is not in notification_channels', () => { + const { user } = createUser(testDb); + // No notification_channels set → defaults to none + const { available_channels } = getPreferencesMatrix(user.id, 'user'); + expect(available_channels.email).toBe(false); + }); + + it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => { + const { user } = createAdmin(testDb); + const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin'); + expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']); + // All events now support all three channels + expect(implemented_combos['trip_invite']).toContain('inapp'); + expect(implemented_combos['trip_invite']).toContain('email'); + expect(implemented_combos['trip_invite']).toContain('webhook'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// setPreferences +// ───────────────────────────────────────────────────────────────────────────── + +describe('setPreferences', () => { + it('NPREF-012 — disabling a preference inserts a row with enabled=0', () => { + const { user } = createUser(testDb); + setPreferences(user.id, { trip_invite: { email: false } }); + const row = testDb.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined; + expect(row).toBeDefined(); + expect(row!.enabled).toBe(0); + }); + + it('NPREF-013 — re-enabling a preference removes the disabled row', () => { + const { user } = createUser(testDb); + // First disable + disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); + // Then re-enable + setPreferences(user.id, { trip_invite: { email: true } }); + const row = testDb.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(user.id, 'trip_invite', 'email'); + // Row should be deleted — default is enabled + expect(row).toBeUndefined(); + }); + + it('NPREF-014 — bulk update handles multiple event+channel combos', () => { + const { user } = createUser(testDb); + setPreferences(user.id, { + trip_invite: { email: false, webhook: false }, + booking_change: { email: false }, + trip_reminder: { webhook: true }, + }); + expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false); + expect(isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false); + expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false); + // trip_reminder webhook was set to true → no row, default enabled + const row = testDb.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(user.id, 'trip_reminder', 'webhook'); + expect(row).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// getActiveChannels +// ───────────────────────────────────────────────────────────────────────────── + +describe('getActiveChannels', () => { + it('NPREF-015 — returns [] when notification_channels is none', () => { + setAppSetting(testDb, 'notification_channels', 'none'); + expect(getActiveChannels()).toEqual([]); + }); + + it('NPREF-016 — returns [email] when notification_channels is email', () => { + setAppSetting(testDb, 'notification_channels', 'email'); + expect(getActiveChannels()).toEqual(['email']); + }); + + it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', () => { + setAppSetting(testDb, 'notification_channels', 'email,webhook'); + expect(getActiveChannels()).toEqual(['email', 'webhook']); + }); + + it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', () => { + // Only set the singular key + setAppSetting(testDb, 'notification_channel', 'webhook'); + // No notification_channels key + expect(getActiveChannels()).toEqual(['webhook']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// getAvailableChannels +// ───────────────────────────────────────────────────────────────────────────── + +describe('getAvailableChannels', () => { + it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => { + setAppSetting(testDb, 'smtp_host', 'mail.example.com'); + const channels = getAvailableChannels(); + expect(channels.email).toBe(true); + expect(channels.inapp).toBe(true); + }); + + it('NPREF-020 — webhook available when admin has enabled the webhook channel', () => { + setNotificationChannels(testDb, 'webhook'); + const channels = getAvailableChannels(); + expect(channels.webhook).toBe(true); + }); + + it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => { + const original = process.env.SMTP_HOST; + process.env.SMTP_HOST = 'env-mail.example.com'; + try { + const channels = getAvailableChannels(); + expect(channels.email).toBe(true); + } finally { + if (original === undefined) delete process.env.SMTP_HOST; + else process.env.SMTP_HOST = original; + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// setAdminPreferences +// ───────────────────────────────────────────────────────────────────────────── + +describe('setAdminPreferences', () => { + it('NPREF-022 — disabling email for version_available stores global pref in app_settings', () => { + const { user } = createAdmin(testDb); + setAdminPreferences(user.id, { version_available: { email: false } }); + expect(getAdminGlobalPref('version_available', 'email')).toBe(false); + const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined; + expect(row?.value).toBe('0'); + }); + + it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', () => { + const { user } = createAdmin(testDb); + setAdminPreferences(user.id, { version_available: { inapp: false } }); + const row = testDb.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined; + expect(row).toBeDefined(); + expect(row!.enabled).toBe(0); + // Global app_settings should NOT have an inapp key + const globalRow = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_inapp'); + expect(globalRow).toBeUndefined(); + }); + + it('NPREF-024 — re-enabling inapp removes the disabled per-user row', () => { + const { user } = createAdmin(testDb); + // First disable + disableNotificationPref(testDb, user.id, 'version_available', 'inapp'); + // Then re-enable via setAdminPreferences + setAdminPreferences(user.id, { version_available: { inapp: true } }); + const row = testDb.prepare( + 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' + ).get(user.id, 'version_available', 'inapp'); + expect(row).toBeUndefined(); + }); + + it('NPREF-025 — enabling email stores global pref as "1" in app_settings', () => { + const { user } = createAdmin(testDb); + // First disable, then re-enable + setAdminPreferences(user.id, { version_available: { email: false } }); + setAdminPreferences(user.id, { version_available: { email: true } }); + expect(getAdminGlobalPref('version_available', 'email')).toBe(true); + const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined; + expect(row?.value).toBe('1'); + }); +}); diff --git a/server/tests/unit/services/notificationService.test.ts b/server/tests/unit/services/notificationService.test.ts new file mode 100644 index 0000000..a6b24f8 --- /dev/null +++ b/server/tests/unit/services/notificationService.test.ts @@ -0,0 +1,459 @@ +/** + * Unit tests for the unified notificationService.send(). + * Covers NSVC-001 to NSVC-014. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/services/apiKeyCrypto', () => ({ + decrypt_api_key: (v: string | null) => v, + maybe_encrypt_api_key: (v: string) => v, + encrypt_api_key: (v: string) => v, +})); + +const { sendMailMock, fetchMock, broadcastMock } = vi.hoisted(() => ({ + sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }), + fetchMock: vi.fn(), + broadcastMock: vi.fn(), +})); + +vi.mock('nodemailer', () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: sendMailMock, + verify: vi.fn().mockResolvedValue(true), + })), + }, +})); + +vi.mock('node-fetch', () => ({ default: fetchMock })); +vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock })); +vi.mock('../../../src/utils/ssrfGuard', () => ({ + checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })), + createPinnedAgent: vi.fn(() => ({})), +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories'; +import { send } from '../../../src/services/notificationService'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function setSmtp(): void { + setAppSetting(testDb, 'smtp_host', 'mail.test.com'); + setAppSetting(testDb, 'smtp_port', '587'); + setAppSetting(testDb, 'smtp_from', 'trek@test.com'); +} + +function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): void { + testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)").run(userId, url); +} + +function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void { + setAppSetting(testDb, 'admin_webhook_url', url); +} + +function getInAppNotifications(recipientId: number) { + return testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ? ORDER BY id').all(recipientId) as Array<{ + id: number; + type: string; + scope: string; + navigate_target: string | null; + navigate_text_key: string | null; + title_key: string; + text_key: string; + }>; +} + +function countAllNotifications(): number { + return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c; +} + +// ── Setup ────────────────────────────────────────────────────────────────── + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + sendMailMock.mockClear(); + fetchMock.mockClear(); + broadcastMock.mockClear(); + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' }); +}); + +afterAll(() => { + testDb.close(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Multi-channel dispatch +// ───────────────────────────────────────────────────────────────────────────── + +describe('send() — multi-channel dispatch', () => { + it('NSVC-001 — dispatches to all 3 channels (inapp, email, webhook) when all are active', async () => { + const { user } = createUser(testDb); + setSmtp(); + setUserWebhookUrl(user.id); + setNotificationChannels(testDb, 'email,webhook'); + testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number; + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); + + expect(sendMailMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(broadcastMock).toHaveBeenCalledTimes(1); + expect(countAllNotifications()).toBe(1); + }); + + it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => { + const { user } = createUser(testDb); + setSmtp(); + setUserWebhookUrl(user.id); + setNotificationChannels(testDb, 'none'); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number; + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); + + expect(sendMailMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(broadcastMock).toHaveBeenCalledTimes(1); + expect(countAllNotifications()).toBe(1); + }); + + it('NSVC-003 — sends only email when only email channel is active', async () => { + const { user } = createUser(testDb); + setSmtp(); + setNotificationChannels(testDb, 'email'); + testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number; + + await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } }); + + expect(sendMailMock).toHaveBeenCalledTimes(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Per-user preference filtering +// ───────────────────────────────────────────────────────────────────────────── + +describe('send() — per-user preference filtering', () => { + it('NSVC-004 — skips email for a user who disabled trip_invite on email channel', async () => { + const { user } = createUser(testDb); + setSmtp(); + setNotificationChannels(testDb, 'email'); + testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number; + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); + + expect(sendMailMock).not.toHaveBeenCalled(); + // in-app still fires + expect(broadcastMock).toHaveBeenCalledTimes(1); + }); + + it('NSVC-005 — skips in-app for a user who disabled the event on inapp channel', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + disableNotificationPref(testDb, user.id, 'collab_message', 'inapp'); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + + await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } }); + + expect(broadcastMock).not.toHaveBeenCalled(); + expect(countAllNotifications()).toBe(0); + }); + + it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => { + const { user } = createUser(testDb); + setSmtp(); + setUserWebhookUrl(user.id); + setNotificationChannels(testDb, 'email,webhook'); + testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number; + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); + + expect(sendMailMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Recipient resolution +// ───────────────────────────────────────────────────────────────────────────── + +describe('send() — recipient resolution', () => { + it('NSVC-007 — trip scope sends to owner + members, excludes actorId', async () => { + const { user: owner } = createUser(testDb); + const { user: member1 } = createUser(testDb); + const { user: member2 } = createUser(testDb); + const { user: actor } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number; + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id); + + await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } }); + + // Owner, member1, member2 get it; actor is excluded + expect(countAllNotifications()).toBe(3); + const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id); + expect(recipients).toContain(owner.id); + expect(recipients).toContain(member1.id); + expect(recipients).toContain(member2.id); + expect(recipients).not.toContain(actor.id); + }); + + it('NSVC-008 — user scope sends to exactly one user', async () => { + const { user: target } = createUser(testDb); + const { user: other } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + + await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } }); + + expect(countAllNotifications()).toBe(1); + const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number }; + expect(notif.recipient_id).toBe(target.id); + }); + + it('NSVC-009 — admin scope sends to all admins (not regular users)', async () => { + const { user: admin1 } = createAdmin(testDb); + const { user: admin2 } = createAdmin(testDb); + createUser(testDb); // regular user — should NOT receive + setNotificationChannels(testDb, 'none'); + + await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } }); + + expect(countAllNotifications()).toBe(2); + const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id); + expect(recipients).toContain(admin1.id); + expect(recipients).toContain(admin2.id); + }); + + it('NSVC-010 — admin scope fires admin webhook URL when set', async () => { + createAdmin(testDb); + setAdminWebhookUrl(); + setNotificationChannels(testDb, 'none'); + + await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } }); + + // Wait for fire-and-forget admin webhook + await new Promise(r => setTimeout(r, 10)); + expect(fetchMock).toHaveBeenCalledTimes(1); + const callUrl = fetchMock.mock.calls[0][0]; + expect(callUrl).toBe('https://hooks.test.com/admin-webhook'); + }); + + it('NSVC-011 — does nothing when there are no recipients', async () => { + // Trip with no members, sending as the trip owner (actor excluded from trip scope) + const { user: owner } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number; + + await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } }); + + expect(countAllNotifications()).toBe(0); + expect(broadcastMock).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// In-app notification content +// ───────────────────────────────────────────────────────────────────────────── + +describe('send() — in-app notification content', () => { + it('NSVC-012 — creates navigate in-app notification with correct title/text/navigate keys', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } }); + + const notifs = getInAppNotifications(user.id); + expect(notifs.length).toBe(1); + expect(notifs[0].type).toBe('navigate'); + expect(notifs[0].title_key).toBe('notif.trip_invite.title'); + expect(notifs[0].text_key).toBe('notif.trip_invite.text'); + expect(notifs[0].navigate_text_key).toBe('notif.action.view_trip'); + expect(notifs[0].navigate_target).toBe('/trips/42'); + }); + + it('NSVC-013 — creates simple in-app notification when no navigate target is available', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + + // vacay_invite without planId → no navigate target → simple type + await send({ event: 'vacay_invite', actorId: null, scope: 'user', targetId: user.id, params: { actor: 'Alice' } }); + + const notifs = getInAppNotifications(user.id); + expect(notifs.length).toBe(1); + expect(notifs[0].type).toBe('simple'); + expect(notifs[0].navigate_target).toBeNull(); + }); + + it('NSVC-014 — navigate_target uses /admin for version_available event', async () => { + const { user: admin } = createAdmin(testDb); + setNotificationChannels(testDb, 'none'); + + await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } }); + + const notifs = getInAppNotifications(admin.id); + expect(notifs.length).toBe(1); + expect(notifs[0].navigate_target).toBe('/admin'); + expect(notifs[0].title_key).toBe('notif.version_available.title'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Email/webhook link generation +// ───────────────────────────────────────────────────────────────────────────── + +describe('send() — email/webhook links', () => { + it('NSVC-015 — email subject and body are localized per recipient language', async () => { + const { user } = createUser(testDb); + setSmtp(); + setNotificationChannels(testDb, 'email'); + testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + // Set user language to French + testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number; + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); + + expect(sendMailMock).toHaveBeenCalledTimes(1); + const mailArgs = sendMailMock.mock.calls[0][0]; + // French title for trip_invite should contain "Invitation" + expect(mailArgs.subject).toContain('Invitation'); + }); + + it('NSVC-016 — webhook payload includes link field when navigate target is available', async () => { + const { user } = createUser(testDb); + setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook'); + setNotificationChannels(testDb, 'webhook'); + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + // Generic webhook — link should contain /trips/55 + expect(body.link).toContain('/trips/55'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Boolean in-app type +// ───────────────────────────────────────────────────────────────────────────── + +describe('send() — boolean in-app type', () => { + it('NSVC-017 — creates boolean in-app notification with callbacks when inApp.type override is boolean', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + + await send({ + event: 'trip_invite', + actorId: null, + scope: 'user', + targetId: user.id, + params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' }, + inApp: { + type: 'boolean', + positiveTextKey: 'notif.action.accept', + negativeTextKey: 'notif.action.decline', + positiveCallback: { action: 'test_approve', payload: { tripId: 1 } }, + negativeCallback: { action: 'test_deny', payload: { tripId: 1 } }, + }, + }); + + const notifs = getInAppNotifications(user.id); + expect(notifs.length).toBe(1); + const row = notifs[0] as any; + expect(row.type).toBe('boolean'); + expect(row.positive_callback).toContain('test_approve'); + expect(row.negative_callback).toContain('test_deny'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Channel failure resilience +// ───────────────────────────────────────────────────────────────────────────── + +describe('send() — channel failure resilience', () => { + it('NSVC-018 — email failure does not prevent in-app or webhook delivery', async () => { + const { user } = createUser(testDb); + setSmtp(); + setUserWebhookUrl(user.id); + setNotificationChannels(testDb, 'email,webhook'); + testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + + // Make email throw + sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused')); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); + + // In-app and webhook still fire despite email failure + expect(broadcastMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(countAllNotifications()).toBe(1); + }); + + it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => { + const { user } = createUser(testDb); + setSmtp(); + setUserWebhookUrl(user.id); + setNotificationChannels(testDb, 'email,webhook'); + testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + + // Make webhook throw + fetchMock.mockRejectedValueOnce(new Error('Network error')); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); + + // In-app and email still fire despite webhook failure + expect(broadcastMock).toHaveBeenCalledTimes(1); + expect(sendMailMock).toHaveBeenCalledTimes(1); + expect(countAllNotifications()).toBe(1); + }); +}); diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/services/notifications.test.ts index 7af5706..70a9dfd 100644 --- a/server/tests/unit/services/notifications.test.ts +++ b/server/tests/unit/services/notifications.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) }, @@ -18,7 +18,15 @@ vi.mock('../../../src/services/auditLog', () => ({ vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } })); vi.mock('node-fetch', () => ({ default: vi.fn() })); -import { getEventText, buildEmailHtml, buildWebhookBody } from '../../../src/services/notifications'; +// ssrfGuard is mocked per-test in the SSRF describe block; default passes all +vi.mock('../../../src/utils/ssrfGuard', () => ({ + checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })), + createPinnedAgent: vi.fn(() => ({})), +})); + +import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications'; +import { checkSsrf } from '../../../src/utils/ssrfGuard'; +import { logError } from '../../../src/services/auditLog'; afterEach(() => { vi.unstubAllEnvs(); @@ -193,3 +201,119 @@ describe('buildEmailHtml', () => { expect(unknown).toContain('notifications enabled in TREK'); }); }); + +// ── SEC: XSS escaping in buildEmailHtml ────────────────────────────────────── + +describe('buildEmailHtml XSS prevention (SEC-016)', () => { + it('escapes HTML special characters in subject', () => { + const html = buildEmailHtml('', 'Body', 'en'); + expect(html).not.toContain('', + }); + const html = buildEmailHtml('Subject', body, 'en'); + expect(html).not.toContain(''); + expect(html).not.toContain('