diff --git a/client/package-lock.json b/client/package-lock.json index d53492b..7c3d867 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -5941,9 +5941,9 @@ "license": "MIT" }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, diff --git a/client/src/App.tsx b/client/src/App.tsx index 46e51a1..d77755a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,10 +11,12 @@ import SettingsPage from './pages/SettingsPage' import VacayPage from './pages/VacayPage' import AtlasPage from './pages/AtlasPage' import SharedTripPage from './pages/SharedTripPage' +import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import { ToastContainer } from './components/shared/Toast' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' +import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' interface ProtectedRouteProps { children: ReactNode @@ -75,15 +77,16 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { if (!location.pathname.startsWith('/shared/')) { loadUser() } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) + if (config?.dev_mode) setDevMode(true) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) if (config?.timezone) setServerTimezone(config.timezone) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) @@ -114,6 +117,8 @@ export default function App() { const { settings } = useSettingsStore() + useInAppNotificationListener() + useEffect(() => { if (isAuthenticated) { loadSettings() @@ -213,6 +218,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index facf652..cb52172 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -184,6 +184,8 @@ export const adminApi = { getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data), updatePermissions: (permissions: Record) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), 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), } export const addonsApi = { @@ -318,4 +320,23 @@ export const notificationsApi = { testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data), } +export const inAppNotificationsApi = { + list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) => + apiClient.get('/notifications/in-app', { params }).then(r => r.data), + unreadCount: () => + apiClient.get('/notifications/in-app/unread-count').then(r => r.data), + markRead: (id: number) => + apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data), + markUnread: (id: number) => + apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data), + markAllRead: () => + apiClient.put('/notifications/in-app/read-all').then(r => r.data), + delete: (id: number) => + apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data), + deleteAll: () => + apiClient.delete('/notifications/in-app/all').then(r => r.data), + respond: (id: number, response: 'positive' | 'negative') => + apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data), +} + export default apiClient diff --git a/client/src/components/Admin/DevNotificationsPanel.tsx b/client/src/components/Admin/DevNotificationsPanel.tsx new file mode 100644 index 0000000..31572ee --- /dev/null +++ b/client/src/components/Admin/DevNotificationsPanel.tsx @@ -0,0 +1,343 @@ +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' + +interface Trip { + id: number + title: string +} + +interface AppUser { + id: number + username: string + email: string +} + +export default function DevNotificationsPanel(): React.ReactElement { + const toast = useToast() + const user = useAuthStore(s => s.user) + const [sending, setSending] = useState(null) + const [trips, setTrips] = useState([]) + const [selectedTripId, setSelectedTripId] = useState(null) + const [users, setUsers] = useState([]) + const [selectedUserId, setSelectedUserId] = useState(null) + + useEffect(() => { + tripsApi.list().then(data => { + const list = (data.trips || data || []) as Trip[] + setTrips(list) + if (list.length > 0) setSelectedTripId(list[0].id) + }).catch(() => {}) + adminApi.users().then(data => { + const list = (data.users || data || []) as AppUser[] + setUsers(list) + if (list.length > 0) setSelectedUserId(list[0].id) + }).catch(() => {}) + }, []) + + const send = async (label: string, payload: Record) => { + setSending(label) + try { + await adminApi.sendTestNotification(payload) + toast.success(`Sent: ${label}`) + } catch (err: any) { + toast.error(err.message || 'Failed') + } finally { + setSending(null) + } + } + + 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' }, + }, + }, + ] + + return ( +
+
+
+ DEV ONLY +
+ + Notification Testing + +
+ +

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

+ + {/* Quick-fire buttons */} +
+

Quick Send

+
+ {buttons.map(btn => { + const Icon = btn.icon + return ( + + ) + })} +
+
+ + {/* Trip-scoped notifications */} + {trips.length > 0 && ( +
+

Trip-Scoped

+
+ +
+
+ + +
+
+ )} + + {/* User-scoped notifications */} + {users.length > 0 && ( +
+

User-Scoped

+
+ +
+
+ + + +
+
+ )} +
+ ) +} diff --git a/client/src/components/Layout/InAppNotificationBell.tsx b/client/src/components/Layout/InAppNotificationBell.tsx new file mode 100644 index 0000000..fcf14cb --- /dev/null +++ b/client/src/components/Layout/InAppNotificationBell.tsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from 'react' +import ReactDOM from 'react-dom' +import { useNavigate } from 'react-router-dom' +import { Bell, Trash2, CheckCheck } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts' +import { useSettingsStore } from '../../store/settingsStore' +import { useAuthStore } from '../../store/authStore' +import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx' + +export default function InAppNotificationBell(): React.ReactElement { + const { t } = useTranslation() + const navigate = useNavigate() + const { settings } = useSettingsStore() + const darkMode = settings.dark_mode + const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) + + const isAuthenticated = useAuthStore(s => s.isAuthenticated) + const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore() + + const [open, setOpen] = useState(false) + + useEffect(() => { + if (isAuthenticated) { + fetchUnreadCount() + } + }, [isAuthenticated]) + + const handleOpen = () => { + if (!open) { + fetchNotifications(true) + } + setOpen(v => !v) + } + + const handleShowAll = () => { + setOpen(false) + navigate('/notifications') + } + + const displayCount = unreadCount > 99 ? '99+' : unreadCount + + return ( +
+ + + {open && ReactDOM.createPortal( + <> +
setOpen(false)} /> +
+ {/* Header */} +
+ + {t('notifications.title')} + {unreadCount > 0 && ( + + {unreadCount} + + )} + +
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && ( + + )} +
+
+ + {/* Notification list */} +
+ {isLoading && notifications.length === 0 ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
+ +

{t('notifications.empty')}

+

{t('notifications.emptyDescription')}

+
+ ) : ( + notifications.slice(0, 10).map(n => ( + setOpen(false)} /> + )) + )} +
+ + {/* Footer */} + +
+ , + document.body + )} +
+ ) +} diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index ea19596..1532693 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -7,6 +7,7 @@ import { useAddonStore } from '../../store/addonStore' import { useTranslation } from '../../i18n' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react' import type { LucideIcon } from 'lucide-react' +import InAppNotificationBell from './InAppNotificationBell.tsx' const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe } @@ -163,6 +164,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: {dark ? : } + {/* Notification bell */} + {user && } + {/* User menu */} {user && (
diff --git a/client/src/components/Notifications/InAppNotificationItem.tsx b/client/src/components/Notifications/InAppNotificationItem.tsx new file mode 100644 index 0000000..a791fe7 --- /dev/null +++ b/client/src/components/Notifications/InAppNotificationItem.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore' +import { useSettingsStore } from '../../store/settingsStore' + +function relativeTime(dateStr: string, locale: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now' + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + const days = Math.floor(hours / 24) + return `${days}d` +} + +interface NotificationItemProps { + notification: InAppNotification + onClose?: () => void +} + +export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement { + const { t, locale } = useTranslation() + const navigate = useNavigate() + const { settings } = useSettingsStore() + const darkMode = settings.dark_mode + const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const [responding, setResponding] = useState(false) + + const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore() + + const handleNavigate = async () => { + if (!notification.is_read) await markRead(notification.id) + if (notification.navigate_target) { + navigate(notification.navigate_target) + onClose?.() + } + } + + const handleRespond = async (response: 'positive' | 'negative') => { + if (responding || notification.response !== null) return + setResponding(true) + await respondToBoolean(notification.id, response) + setResponding(false) + } + + const titleText = t(notification.title_key, notification.title_params) + const bodyText = t(notification.text_key, notification.text_params) + const hasUnknownTitle = titleText === notification.title_key + const hasUnknownBody = bodyText === notification.text_key + + return ( +
+ {/* Unread dot */} + {!notification.is_read && ( +
+ )} + +
+ {/* Sender avatar */} +
+ {notification.sender_avatar ? ( + + ) : ( +
+ {notification.sender_username + ? notification.sender_username.charAt(0).toUpperCase() + : + } +
+ )} +
+ + {/* Content */} +
+
+

+ {hasUnknownTitle ? notification.title_key : titleText} +

+
+ + {relativeTime(notification.created_at, locale)} + + {!notification.is_read && ( + + )} + +
+
+ +

+ {hasUnknownBody ? notification.text_key : bodyText} +

+ + {/* Boolean actions */} + {notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && ( +
+ + +
+ )} + + {/* Navigate action */} + {notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && ( + + )} +
+
+
+ ) +} diff --git a/client/src/hooks/useInAppNotificationListener.ts b/client/src/hooks/useInAppNotificationListener.ts new file mode 100644 index 0000000..d5fced1 --- /dev/null +++ b/client/src/hooks/useInAppNotificationListener.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react' +import { addListener, removeListener } from '../api/websocket' +import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts' + +export function useInAppNotificationListener(): void { + const handleNew = useInAppNotificationStore(s => s.handleNewNotification) + const handleUpdated = useInAppNotificationStore(s => s.handleUpdatedNotification) + + useEffect(() => { + const listener = (event: Record) => { + if (event.type === 'notification:new') { + handleNew(event.notification as any) + } else if (event.type === 'notification:updated') { + handleUpdated(event.notification as any) + } + } + addListener(listener) + return () => removeListener(listener) + }, [handleNew, handleUpdated]) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 39e2dfc..cd22c0c 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1501,6 +1501,19 @@ const ar: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'الإشعارات', + 'notifications.markAllRead': 'تحديد الكل كمقروء', + 'notifications.deleteAll': 'حذف الكل', + 'notifications.showAll': 'عرض جميع الإشعارات', + 'notifications.empty': 'لا توجد إشعارات', + 'notifications.emptyDescription': 'لقد اطلعت على كل شيء!', + 'notifications.all': 'الكل', + 'notifications.unreadOnly': 'غير مقروء', + 'notifications.markRead': 'تحديد كمقروء', + 'notifications.markUnread': 'تحديد كغير مقروء', + 'notifications.delete': 'حذف', + 'notifications.system': 'النظام', } export default ar diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 073f83e..b22072d 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1496,6 +1496,19 @@ const br: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'Notificações', + 'notifications.markAllRead': 'Marcar tudo como lido', + 'notifications.deleteAll': 'Excluir tudo', + 'notifications.showAll': 'Ver todas as notificações', + 'notifications.empty': 'Sem notificações', + 'notifications.emptyDescription': 'Você está em dia!', + 'notifications.all': 'Todas', + 'notifications.unreadOnly': 'Não lidas', + 'notifications.markRead': 'Marcar como lido', + 'notifications.markUnread': 'Marcar como não lido', + 'notifications.delete': 'Excluir', + 'notifications.system': 'Sistema', } export default br diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index b4c1bc6..559e547 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1499,6 +1499,19 @@ const cs: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'Oznámení', + 'notifications.markAllRead': 'Označit vše jako přečtené', + 'notifications.deleteAll': 'Smazat vše', + 'notifications.showAll': 'Zobrazit všechna oznámení', + 'notifications.empty': 'Žádná oznámení', + 'notifications.emptyDescription': 'Vše máte přečteno!', + 'notifications.all': 'Vše', + 'notifications.unreadOnly': 'Nepřečtené', + 'notifications.markRead': 'Označit jako přečtené', + 'notifications.markUnread': 'Označit jako nepřečtené', + 'notifications.delete': 'Smazat', + 'notifications.system': 'Systém', } export default cs diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index fba93d6..c118948 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1498,6 +1498,19 @@ const de: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'Benachrichtigungen', + 'notifications.markAllRead': 'Alle als gelesen markieren', + 'notifications.deleteAll': 'Alle löschen', + 'notifications.showAll': 'Alle Benachrichtigungen anzeigen', + 'notifications.empty': 'Keine Benachrichtigungen', + 'notifications.emptyDescription': 'Sie sind auf dem neuesten Stand!', + 'notifications.all': 'Alle', + 'notifications.unreadOnly': 'Ungelesen', + 'notifications.markRead': 'Als gelesen markieren', + 'notifications.markUnread': 'Als ungelesen markieren', + 'notifications.delete': 'Löschen', + 'notifications.system': 'System', } export default de diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 39d848b..5be3f50 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1505,6 +1505,35 @@ const en: Record = { 'undo.importGoogleList': 'Google Maps import', 'undo.addPlace': 'Place added', 'undo.done': 'Undone: {action}', + + // Notifications + 'notifications.title': 'Notifications', + 'notifications.markAllRead': 'Mark all read', + 'notifications.deleteAll': 'Delete all', + 'notifications.showAll': 'Show all notifications', + 'notifications.empty': 'No notifications', + 'notifications.emptyDescription': "You're all caught up!", + 'notifications.all': 'All', + 'notifications.unreadOnly': 'Unread', + 'notifications.markRead': 'Mark as read', + 'notifications.markUnread': 'Mark as unread', + 'notifications.delete': 'Delete', + 'notifications.system': 'System', + + // Notification test keys (dev only) + 'notifications.test.title': 'Test notification from {actor}', + 'notifications.test.text': 'This is a simple test notification.', + 'notifications.test.booleanTitle': '{actor} asks for your approval', + 'notifications.test.booleanText': 'This is a test boolean notification. Choose an action below.', + 'notifications.test.accept': 'Approve', + 'notifications.test.decline': 'Decline', + 'notifications.test.navigateTitle': 'Check something out', + 'notifications.test.navigateText': 'This is a test navigate notification.', + 'notifications.test.goThere': 'Go there', + 'notifications.test.adminTitle': 'Admin broadcast', + 'notifications.test.adminText': '{actor} sent a test notification to all admins.', + 'notifications.test.tripTitle': '{actor} posted in your trip', + 'notifications.test.tripText': 'Test notification for trip "{trip}".', } export default en diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 3076e04..381ab28 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1503,6 +1503,19 @@ const es: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'Notificaciones', + 'notifications.markAllRead': 'Marcar todo como leído', + 'notifications.deleteAll': 'Eliminar todo', + 'notifications.showAll': 'Ver todas las notificaciones', + 'notifications.empty': 'Sin notificaciones', + 'notifications.emptyDescription': '¡Estás al día!', + 'notifications.all': 'Todas', + 'notifications.unreadOnly': 'No leídas', + 'notifications.markRead': 'Marcar como leída', + 'notifications.markUnread': 'Marcar como no leída', + 'notifications.delete': 'Eliminar', + 'notifications.system': 'Sistema', } export default es diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index eadad57..da25c20 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1497,6 +1497,19 @@ const fr: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'Notifications', + 'notifications.markAllRead': 'Tout marquer comme lu', + 'notifications.deleteAll': 'Tout supprimer', + 'notifications.showAll': 'Voir toutes les notifications', + 'notifications.empty': 'Aucune notification', + 'notifications.emptyDescription': 'Vous êtes à jour !', + 'notifications.all': 'Toutes', + 'notifications.unreadOnly': 'Non lues', + 'notifications.markRead': 'Marquer comme lu', + 'notifications.markUnread': 'Marquer comme non lu', + 'notifications.delete': 'Supprimer', + 'notifications.system': 'Système', } export default fr diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 2d577f9..a567d1c 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1498,6 +1498,19 @@ const hu: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'Értesítések', + 'notifications.markAllRead': 'Összes olvasottnak jelölése', + 'notifications.deleteAll': 'Összes törlése', + 'notifications.showAll': 'Összes értesítés megtekintése', + 'notifications.empty': 'Nincsenek értesítések', + 'notifications.emptyDescription': 'Mindennel naprakész vagy!', + 'notifications.all': 'Összes', + 'notifications.unreadOnly': 'Olvasatlan', + 'notifications.markRead': 'Olvasottnak jelölés', + 'notifications.markUnread': 'Olvasatlannak jelölés', + 'notifications.delete': 'Törlés', + 'notifications.system': 'Rendszer', } export default hu diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index e77cd5e..fac9285 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1500,6 +1500,19 @@ const it: Record = { 'undo.importGoogleList': 'Importazione Google Maps', 'undo.addPlace': 'Luogo aggiunto', 'undo.done': 'Annullato: {action}', + // Notifications + 'notifications.title': 'Notifiche', + 'notifications.markAllRead': 'Segna tutto come letto', + 'notifications.deleteAll': 'Elimina tutto', + 'notifications.showAll': 'Vedi tutte le notifiche', + 'notifications.empty': 'Nessuna notifica', + 'notifications.emptyDescription': 'Sei aggiornato!', + 'notifications.all': 'Tutte', + 'notifications.unreadOnly': 'Non lette', + 'notifications.markRead': 'Segna come letto', + 'notifications.markUnread': 'Segna come non letto', + 'notifications.delete': 'Elimina', + 'notifications.system': 'Sistema', } export default it diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 8bdcee8..6c88ef6 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1497,6 +1497,19 @@ const nl: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'Meldingen', + 'notifications.markAllRead': 'Alles als gelezen markeren', + 'notifications.deleteAll': 'Alles verwijderen', + 'notifications.showAll': 'Alle meldingen weergeven', + 'notifications.empty': 'Geen meldingen', + 'notifications.emptyDescription': 'Je bent helemaal bijgewerkt!', + 'notifications.all': 'Alle', + 'notifications.unreadOnly': 'Ongelezen', + 'notifications.markRead': 'Markeren als gelezen', + 'notifications.markUnread': 'Markeren als ongelezen', + 'notifications.delete': 'Verwijderen', + 'notifications.system': 'Systeem', } export default nl diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index c4a56e4..9e1bb55 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1497,6 +1497,19 @@ const ru: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': 'Уведомления', + 'notifications.markAllRead': 'Отметить все прочитанными', + 'notifications.deleteAll': 'Удалить все', + 'notifications.showAll': 'Показать все уведомления', + 'notifications.empty': 'Нет уведомлений', + 'notifications.emptyDescription': 'Вы в курсе всех событий!', + 'notifications.all': 'Все', + 'notifications.unreadOnly': 'Непрочитанные', + 'notifications.markRead': 'Отметить как прочитанное', + 'notifications.markUnread': 'Отметить как непрочитанное', + 'notifications.delete': 'Удалить', + 'notifications.system': 'Система', } export default ru diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 285375f..2f6eade 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1497,6 +1497,19 @@ const zh: Record = { 'undo.importGpx': 'GPX import', 'undo.importGoogleList': 'Google Maps import', + // Notifications + 'notifications.title': '通知', + 'notifications.markAllRead': '全部标为已读', + 'notifications.deleteAll': '全部删除', + 'notifications.showAll': '查看所有通知', + 'notifications.empty': '暂无通知', + 'notifications.emptyDescription': '您已全部查阅!', + 'notifications.all': '全部', + 'notifications.unreadOnly': '未读', + 'notifications.markRead': '标为已读', + 'notifications.markUnread': '标为未读', + 'notifications.delete': '删除', + 'notifications.system': '系统', } export default zh diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 54f7acf..62ac633 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import apiClient, { adminApi, authApi, notificationsApi } from '../api/client' +import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { useAddonStore } from '../store/addonStore' @@ -61,6 +62,7 @@ export default function AdminPage(): React.ReactElement { const { t, locale } = useTranslation() const hour12 = useSettingsStore(s => s.settings.time_format) === '12h' const mcpEnabled = useAddonStore(s => s.isEnabled('mcp')) + const devMode = useAuthStore(s => s.devMode) const TABS = [ { id: 'users', label: t('admin.tabs.users') }, { id: 'config', label: t('admin.tabs.config') }, @@ -70,6 +72,7 @@ export default function AdminPage(): React.ReactElement { { id: 'audit', label: t('admin.tabs.audit') }, ...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []), { id: 'github', label: t('admin.tabs.github') }, + ...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []), ] const [activeTab, setActiveTab] = useState('users') @@ -1183,6 +1186,8 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'mcp-tokens' && } {activeTab === 'github' && } + + {activeTab === 'dev-notifications' && }
diff --git a/client/src/pages/InAppNotificationsPage.tsx b/client/src/pages/InAppNotificationsPage.tsx new file mode 100644 index 0000000..c6a659f --- /dev/null +++ b/client/src/pages/InAppNotificationsPage.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Bell, CheckCheck, Trash2 } from 'lucide-react' +import { useTranslation } from '../i18n' +import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts' +import { useSettingsStore } from '../store/settingsStore' +import Navbar from '../components/Layout/Navbar' +import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx' + +export default function InAppNotificationsPage(): React.ReactElement { + const { t } = useTranslation() + const { settings } = useSettingsStore() + const darkMode = settings.dark_mode + const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) + + const { notifications, unreadCount, total, isLoading, hasMore, fetchNotifications, markAllRead, deleteAll } = useInAppNotificationStore() + const [unreadOnly, setUnreadOnly] = useState(false) + const loaderRef = useRef(null) + + useEffect(() => { + fetchNotifications(true) + }, []) + + // Reload when filter changes + useEffect(() => { + // We need to fetch with the unreadOnly filter — re-fetch from scratch + // The store fetchNotifications doesn't take a filter param directly, + // so we use the API directly for filtered view via a side channel. + // For now, reset and fetch — store always loads all, filter is client-side. + fetchNotifications(true) + }, [unreadOnly]) + + // Infinite scroll + useEffect(() => { + if (!loaderRef.current) return + const observer = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && hasMore && !isLoading) { + fetchNotifications(false) + } + }, { threshold: 0.1 }) + observer.observe(loaderRef.current) + return () => observer.disconnect() + }, [hasMore, isLoading]) + + const displayed = unreadOnly ? notifications.filter(n => !n.is_read) : notifications + + return ( +
+ +
+
+ {/* Header */} +
+
+

+ {t('notifications.title')} + {unreadCount > 0 && ( + + {unreadCount} + + )} +

+

+ {total} {total === 1 ? 'notification' : 'notifications'} +

+
+ + {/* Bulk actions */} +
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && ( + + )} +
+
+ + {/* Filter toggle */} +
+ + +
+ + {/* Notification list */} +
+ {isLoading && displayed.length === 0 ? ( +
+
+
+ ) : displayed.length === 0 ? ( +
+ +

{t('notifications.empty')}

+

{t('notifications.emptyDescription')}

+
+ ) : ( + displayed.map(n => ( + + )) + )} + + {/* Infinite scroll trigger */} + {hasMore && ( +
+ {isLoading &&
} +
+ )} +
+
+
+
+ ) +} diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index a98bd65..fca17d0 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -21,6 +21,7 @@ interface AuthState { isLoading: boolean error: string | null demoMode: boolean + devMode: boolean hasMapsKey: boolean serverTimezone: string /** Server policy: all users must enable MFA */ @@ -39,6 +40,7 @@ interface AuthState { uploadAvatar: (file: File) => Promise deleteAvatar: () => Promise setDemoMode: (val: boolean) => void + setDevMode: (val: boolean) => void setHasMapsKey: (val: boolean) => void setServerTimezone: (tz: string) => void setAppRequireMfa: (val: boolean) => void @@ -52,6 +54,7 @@ export const useAuthStore = create((set, get) => ({ isLoading: true, error: null, demoMode: localStorage.getItem('demo_mode') === 'true', + devMode: false, hasMapsKey: false, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, appRequireMfa: false, @@ -209,6 +212,7 @@ export const useAuthStore = create((set, get) => ({ set({ demoMode: val }) }, + setDevMode: (val: boolean) => set({ devMode: val }), setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }), setServerTimezone: (tz: string) => set({ serverTimezone: tz }), setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), diff --git a/client/src/store/inAppNotificationStore.ts b/client/src/store/inAppNotificationStore.ts new file mode 100644 index 0000000..5f8fc97 --- /dev/null +++ b/client/src/store/inAppNotificationStore.ts @@ -0,0 +1,192 @@ +import { create } from 'zustand' +import { inAppNotificationsApi } from '../api/client' + +export interface InAppNotification { + id: number + type: 'simple' | 'boolean' | 'navigate' + scope: 'trip' | 'user' | 'admin' + target: number + sender_id: number | null + sender_username: string | null + sender_avatar: string | null + recipient_id: number + title_key: string + title_params: Record + text_key: string + text_params: Record + positive_text_key: string | null + negative_text_key: string | null + response: 'positive' | 'negative' | null + navigate_text_key: string | null + navigate_target: string | null + is_read: boolean + created_at: string +} + +interface RawNotification extends Omit { + title_params: string | Record + text_params: string | Record + is_read: number | boolean +} + +function normalizeNotification(raw: RawNotification): InAppNotification { + return { + ...raw, + title_params: typeof raw.title_params === 'string' ? JSON.parse(raw.title_params || '{}') : raw.title_params, + text_params: typeof raw.text_params === 'string' ? JSON.parse(raw.text_params || '{}') : raw.text_params, + is_read: Boolean(raw.is_read), + } +} + +interface NotificationState { + notifications: InAppNotification[] + unreadCount: number + total: number + isLoading: boolean + hasMore: boolean + + fetchNotifications: (reset?: boolean) => Promise + fetchUnreadCount: () => Promise + markRead: (id: number) => Promise + markUnread: (id: number) => Promise + markAllRead: () => Promise + deleteNotification: (id: number) => Promise + deleteAll: () => Promise + respondToBoolean: (id: number, response: 'positive' | 'negative') => Promise + + handleNewNotification: (notification: RawNotification) => void + handleUpdatedNotification: (notification: RawNotification) => void +} + +const PAGE_SIZE = 20 + +export const useInAppNotificationStore = create((set, get) => ({ + notifications: [], + unreadCount: 0, + total: 0, + isLoading: false, + hasMore: false, + + fetchNotifications: async (reset = false) => { + const { notifications, isLoading } = get() + if (isLoading) return + + set({ isLoading: true }) + try { + const offset = reset ? 0 : notifications.length + const data = await inAppNotificationsApi.list({ limit: PAGE_SIZE, offset }) + const normalized = (data.notifications as RawNotification[]).map(normalizeNotification) + + set({ + notifications: reset ? normalized : [...notifications, ...normalized], + total: data.total, + unreadCount: data.unread_count, + hasMore: (reset ? normalized.length : notifications.length + normalized.length) < data.total, + isLoading: false, + }) + } catch { + set({ isLoading: false }) + } + }, + + fetchUnreadCount: async () => { + try { + const data = await inAppNotificationsApi.unreadCount() + set({ unreadCount: data.count }) + } catch { + // best-effort + } + }, + + markRead: async (id: number) => { + try { + await inAppNotificationsApi.markRead(id) + set(state => ({ + notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: true } : n), + unreadCount: Math.max(0, state.unreadCount - (state.notifications.find(n => n.id === id)?.is_read ? 0 : 1)), + })) + } catch { + // best-effort + } + }, + + markUnread: async (id: number) => { + try { + await inAppNotificationsApi.markUnread(id) + set(state => ({ + notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: false } : n), + unreadCount: state.unreadCount + (state.notifications.find(n => n.id === id)?.is_read ? 1 : 0), + })) + } catch { + // best-effort + } + }, + + markAllRead: async () => { + try { + await inAppNotificationsApi.markAllRead() + set(state => ({ + notifications: state.notifications.map(n => ({ ...n, is_read: true })), + unreadCount: 0, + })) + } catch { + // best-effort + } + }, + + deleteNotification: async (id: number) => { + const notification = get().notifications.find(n => n.id === id) + try { + await inAppNotificationsApi.delete(id) + set(state => ({ + notifications: state.notifications.filter(n => n.id !== id), + total: Math.max(0, state.total - 1), + unreadCount: notification && !notification.is_read ? Math.max(0, state.unreadCount - 1) : state.unreadCount, + })) + } catch { + // best-effort + } + }, + + deleteAll: async () => { + try { + await inAppNotificationsApi.deleteAll() + set({ notifications: [], total: 0, unreadCount: 0, hasMore: false }) + } catch { + // best-effort + } + }, + + respondToBoolean: async (id: number, response: 'positive' | 'negative') => { + try { + const data = await inAppNotificationsApi.respond(id, response) + if (data.notification) { + const normalized = normalizeNotification(data.notification as RawNotification) + set(state => ({ + notifications: state.notifications.map(n => n.id === id ? normalized : n), + unreadCount: !state.notifications.find(n => n.id === id)?.is_read + ? Math.max(0, state.unreadCount - 1) + : state.unreadCount, + })) + } + } catch { + // best-effort + } + }, + + handleNewNotification: (raw: RawNotification) => { + const notification = normalizeNotification(raw) + set(state => ({ + notifications: [notification, ...state.notifications], + total: state.total + 1, + unreadCount: state.unreadCount + 1, + })) + }, + + handleUpdatedNotification: (raw: RawNotification) => { + const notification = normalizeNotification(raw) + set(state => ({ + notifications: state.notifications.map(n => n.id === notification.id ? notification : n), + })) + }, +})) diff --git a/server/package-lock.json b/server/package-lock.json index e85a970..8898484 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2830,9 +2830,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.includes": { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index d0b0a5e..c2f1271 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -491,6 +491,33 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); `); }, + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), + scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), + target INTEGER NOT NULL, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title_key TEXT NOT NULL, + title_params TEXT DEFAULT '{}', + text_key TEXT NOT NULL, + text_params TEXT DEFAULT '{}', + positive_text_key TEXT, + negative_text_key TEXT, + positive_callback TEXT, + negative_callback TEXT, + response TEXT CHECK(response IN ('positive', 'negative')), + navigate_text_key TEXT, + navigate_target TEXT, + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + 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); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 0eb24b7..8506253 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -394,6 +394,30 @@ function createTables(db: Database.Database): void { ip TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); + + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), + scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), + target INTEGER NOT NULL, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title_key TEXT NOT NULL, + title_params TEXT DEFAULT '{}', + text_key TEXT NOT NULL, + text_params TEXT DEFAULT '{}', + positive_text_key TEXT, + negative_text_key TEXT, + positive_callback TEXT, + negative_callback TEXT, + response TEXT CHECK(response IN ('positive', 'negative')), + navigate_text_key TEXT, + navigate_target TEXT, + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + 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); `); } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 12481c8..56a9136 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -311,4 +311,44 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => { res.json({ success: true }); }); +// ── Dev-only: test notification endpoints ────────────────────────────────────── +if (process.env.NODE_ENV === 'development') { + const { createNotification } = require('../services/inAppNotifications'); + + router.post('/dev/test-notification', (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'; + } + + try { + const ids = createNotification(input); + res.json({ success: true, notification_ids: ids }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); +} + export default router; diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts index d8e3d00..0d7168f 100644 --- a/server/src/routes/notifications.ts +++ b/server/src/routes/notifications.ts @@ -2,6 +2,16 @@ import express, { Request, Response } from 'express'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; import { testSmtp, testWebhook } from '../services/notifications'; +import { + getNotifications, + getUnreadCount, + markRead, + markUnread, + markAllRead, + deleteNotification, + deleteAll, + respondToBoolean, +} from '../services/inAppNotifications'; import * as prefsService from '../services/notificationPreferencesService'; const router = express.Router(); @@ -33,4 +43,87 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) = res.json(await testWebhook()); }); +// ── In-app notifications ────────────────────────────────────────────────────── + +// GET /in-app — list notifications (paginated) +router.get('/in-app', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const offset = parseInt(req.query.offset as string) || 0; + const unreadOnly = req.query.unread_only === 'true'; + + const result = getNotifications(authReq.user.id, { limit, offset, unreadOnly }); + res.json(result); +}); + +// GET /in-app/unread-count — badge count +router.get('/in-app/unread-count', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const count = getUnreadCount(authReq.user.id); + res.json({ count }); +}); + +// PUT /in-app/read-all — mark all read (must be before /:id routes) +router.put('/in-app/read-all', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const count = markAllRead(authReq.user.id); + res.json({ success: true, count }); +}); + +// DELETE /in-app/all — delete all (must be before /:id routes) +router.delete('/in-app/all', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const count = deleteAll(authReq.user.id); + res.json({ success: true, count }); +}); + +// PUT /in-app/:id/read — mark single read +router.put('/in-app/:id/read', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); + + const ok = markRead(id, authReq.user.id); + if (!ok) return res.status(404).json({ error: 'Not found' }); + res.json({ success: true }); +}); + +// PUT /in-app/:id/unread — mark single unread +router.put('/in-app/:id/unread', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); + + const ok = markUnread(id, authReq.user.id); + if (!ok) return res.status(404).json({ error: 'Not found' }); + res.json({ success: true }); +}); + +// DELETE /in-app/:id — delete single +router.delete('/in-app/:id', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); + + const ok = deleteNotification(id, authReq.user.id); + if (!ok) return res.status(404).json({ error: 'Not found' }); + res.json({ success: true }); +}); + +// POST /in-app/:id/respond — respond to a boolean notification +router.post('/in-app/:id/respond', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); + + const { response } = req.body; + if (response !== 'positive' && response !== 'negative') { + return res.status(400).json({ error: 'response must be "positive" or "negative"' }); + } + + const result = await respondToBoolean(id, authReq.user.id, response); + if (!result.success) return res.status(400).json({ error: result.error }); + res.json({ success: true, notification: result.notification }); +}); + export default router; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 361cda2..c069645 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -218,6 +218,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { notification_channel: notifChannel, trip_reminders_enabled: tripRemindersEnabled, permissions: authenticatedUser ? getAllPermissions() : undefined, + dev_mode: process.env.NODE_ENV === 'development', }; } diff --git a/server/src/services/inAppNotificationActions.ts b/server/src/services/inAppNotificationActions.ts new file mode 100644 index 0000000..991ad78 --- /dev/null +++ b/server/src/services/inAppNotificationActions.ts @@ -0,0 +1,22 @@ +type ActionHandler = (payload: Record, respondingUserId: number) => Promise; + +const actionRegistry = new Map(); + +function registerAction(actionType: string, handler: ActionHandler): void { + actionRegistry.set(actionType, handler); +} + +function getAction(actionType: string): ActionHandler | undefined { + return actionRegistry.get(actionType); +} + +// Dev/test actions +registerAction('test_approve', async () => { + console.log('[notifications] Test approve action executed'); +}); + +registerAction('test_deny', async () => { + console.log('[notifications] Test deny action executed'); +}); + +export { registerAction, getAction }; diff --git a/server/src/services/inAppNotifications.ts b/server/src/services/inAppNotifications.ts new file mode 100644 index 0000000..8cb6f53 --- /dev/null +++ b/server/src/services/inAppNotifications.ts @@ -0,0 +1,332 @@ +import { db } from '../db/database'; +import { broadcastToUser } from '../websocket'; +import { getAction } from './notificationActions'; + +type NotificationType = 'simple' | 'boolean' | 'navigate'; +type NotificationScope = 'trip' | 'user' | 'admin'; +type NotificationResponse = 'positive' | 'negative'; + +interface BaseNotificationInput { + type: NotificationType; + scope: NotificationScope; + target: number; + sender_id: number | null; + title_key: string; + title_params?: Record; + text_key: string; + text_params?: Record; +} + +interface SimpleNotificationInput extends BaseNotificationInput { + type: 'simple'; +} + +interface BooleanNotificationInput extends BaseNotificationInput { + type: 'boolean'; + positive_text_key: string; + negative_text_key: string; + positive_callback: { action: string; payload: Record }; + negative_callback: { action: string; payload: Record }; +} + +interface NavigateNotificationInput extends BaseNotificationInput { + type: 'navigate'; + navigate_text_key: string; + navigate_target: string; +} + +type NotificationInput = SimpleNotificationInput | BooleanNotificationInput | NavigateNotificationInput; + +interface NotificationRow { + id: number; + type: NotificationType; + scope: NotificationScope; + target: number; + sender_id: number | null; + sender_username?: string | null; + sender_avatar?: string | null; + recipient_id: number; + title_key: string; + title_params: string; + text_key: string; + text_params: string; + positive_text_key: string | null; + negative_text_key: string | null; + positive_callback: string | null; + negative_callback: string | null; + response: NotificationResponse | null; + navigate_text_key: string | null; + navigate_target: string | null; + is_read: number; + created_at: string; +} + +function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] { + let userIds: number[] = []; + + if (scope === 'trip') { + const owner = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(target) as { user_id: number } | undefined; + const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(target) as { user_id: number }[]; + const ids = new Set(); + if (owner) ids.add(owner.user_id); + for (const m of members) ids.add(m.user_id); + userIds = Array.from(ids); + } else if (scope === 'user') { + userIds = [target]; + } else if (scope === 'admin') { + const admins = db.prepare('SELECT id FROM users WHERE role = ?').all('admin') as { id: number }[]; + userIds = admins.map(a => a.id); + } + + // Only exclude sender for group scopes (trip/admin) — for user scope, the target is explicit + if (excludeUserId != null && scope !== 'user') { + userIds = userIds.filter(id => id !== excludeUserId); + } + + return userIds; +} + +function createNotification(input: NotificationInput): number[] { + const recipients = resolveRecipients(input.scope, input.target, input.sender_id); + if (recipients.length === 0) return []; + + const titleParams = JSON.stringify(input.title_params ?? {}); + const textParams = JSON.stringify(input.text_params ?? {}); + + const insertedIds: number[] = []; + + const insert = db.transaction(() => { + const stmt = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const recipientId of recipients) { + 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 = stmt.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 + ); + + insertedIds.push(result.lastInsertRowid as number); + } + }); + + insert(); + + // Fetch sender info once for WS payloads + const sender = input.sender_id + ? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(input.sender_id) as { username: string; avatar: string | null } | undefined) + : null; + + // Broadcast to each recipient + for (let i = 0; i < insertedIds.length; i++) { + const notificationId = insertedIds[i]; + const recipientId = recipients[i]; + const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow; + if (!row) continue; + + broadcastToUser(recipientId, { + type: 'notification:new', + notification: { + ...row, + sender_username: sender?.username ?? null, + sender_avatar: sender?.avatar ?? null, + }, + }); + } + + return insertedIds; +} + +function getNotifications( + userId: number, + options: { limit?: number; offset?: number; unreadOnly?: boolean } = {} +): { notifications: NotificationRow[]; total: number; unread_count: number } { + const limit = Math.min(options.limit ?? 20, 50); + const offset = options.offset ?? 0; + const unreadOnly = options.unreadOnly ?? false; + + const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?'; + const wherePlain = unreadOnly ? 'WHERE recipient_id = ? AND is_read = 0' : 'WHERE recipient_id = ?'; + + const rows = db.prepare(` + SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar + FROM notifications n + LEFT JOIN users u ON n.sender_id = u.id + ${whereAliased} + ORDER BY n.created_at DESC + LIMIT ? OFFSET ? + `).all(userId, limit, offset) as NotificationRow[]; + + const { total } = db.prepare(`SELECT COUNT(*) as total FROM notifications ${wherePlain}`).get(userId) as { total: number }; + const { unread_count } = db.prepare('SELECT COUNT(*) as unread_count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { unread_count: number }; + + return { notifications: rows, total, unread_count }; +} + +function getUnreadCount(userId: number): number { + const row = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { count: number }; + return row.count; +} + +function markRead(notificationId: number, userId: number): boolean { + const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND recipient_id = ?').run(notificationId, userId); + return result.changes > 0; +} + +function markUnread(notificationId: number, userId: number): boolean { + const result = db.prepare('UPDATE notifications SET is_read = 0 WHERE id = ? AND recipient_id = ?').run(notificationId, userId); + return result.changes > 0; +} + +function markAllRead(userId: number): number { + const result = db.prepare('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? AND is_read = 0').run(userId); + return result.changes; +} + +function deleteNotification(notificationId: number, userId: number): boolean { + const result = db.prepare('DELETE FROM notifications WHERE id = ? AND recipient_id = ?').run(notificationId, userId); + return result.changes > 0; +} + +function deleteAll(userId: number): number { + const result = db.prepare('DELETE FROM notifications WHERE recipient_id = ?').run(userId); + return result.changes; +} + +async function respondToBoolean( + notificationId: number, + userId: number, + response: NotificationResponse +): Promise<{ success: boolean; error?: string; notification?: NotificationRow }> { + const notification = db.prepare('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?').get(notificationId, userId) as NotificationRow | undefined; + + if (!notification) return { success: false, error: 'Notification not found' }; + if (notification.type !== 'boolean') return { success: false, error: 'Not a boolean notification' }; + if (notification.response !== null) return { success: false, error: 'Already responded' }; + + const callbackJson = response === 'positive' ? notification.positive_callback : notification.negative_callback; + if (!callbackJson) return { success: false, error: 'No callback defined' }; + + let callback: { action: string; payload: Record }; + try { + callback = JSON.parse(callbackJson); + } catch { + return { success: false, error: 'Invalid callback format' }; + } + + const handler = getAction(callback.action); + if (!handler) return { success: false, error: `Unknown action: ${callback.action}` }; + + try { + await handler(callback.payload, userId); + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Action failed' }; + } + + // Atomic update — only updates if response is still NULL (prevents double-response) + const result = db.prepare( + 'UPDATE notifications SET response = ?, is_read = 1 WHERE id = ? AND recipient_id = ? AND response IS NULL' + ).run(response, notificationId, userId); + + if (result.changes === 0) return { success: false, error: 'Already responded' }; + + const updated = db.prepare(` + SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar + FROM notifications n + LEFT JOIN users u ON n.sender_id = u.id + WHERE n.id = ? + `).get(notificationId) as NotificationRow; + + broadcastToUser(userId, { type: 'notification:updated', notification: updated }); + + 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, + getUnreadCount, + markRead, + markUnread, + markAllRead, + deleteNotification, + deleteAll, + respondToBoolean, + getPreferences, + updatePreferences, +}; + +export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };