feat: add in-app notification system with real-time delivery

Introduces a full in-app notification system with three types (simple,
boolean with server-side callbacks, navigate), three scopes (user, trip,
admin), fan-out persistence per recipient, and real-time push via
WebSocket. Includes a notification bell in the navbar, dropdown, dedicated
/notifications page, and a dev-only admin tab for testing all notification
variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jubnl
2026-04-02 18:57:52 +02:00
parent 979322025d
commit c0e9a771d6
32 changed files with 1837 additions and 8 deletions

View File

@@ -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"
},

View File

@@ -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 NotificationsPage from './pages/NotificationsPage'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useNotificationListener } from './hooks/useNotificationListener'
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<string, PermissionLevel> }) => {
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<string, PermissionLevel> }) => {
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()
useNotificationListener()
useEffect(() => {
if (isAuthenticated) {
loadSettings()
@@ -213,6 +218,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<NotificationsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>

View File

@@ -184,6 +184,8 @@ export const adminApi = {
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
sendTestNotification: (data: Record<string, unknown>) =>
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

View File

@@ -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<string | null>(null)
const [trips, setTrips] = useState<Trip[]>([])
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
const [users, setUsers] = useState<AppUser[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | null>(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<string, unknown>) => {
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 (
<div className="space-y-6">
<div className="flex items-center gap-2 mb-2">
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
DEV ONLY
</div>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Notification Testing
</span>
</div>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
</p>
{/* Quick-fire buttons */}
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{buttons.map(btn => {
const Icon = btn.icon
return (
<button
key={btn.label}
onClick={() => send(btn.label, btn.payload)}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: `${btn.color}20`, color: btn.color }}>
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
{btn.payload.type} · {btn.payload.scope}
</p>
</div>
{sending === btn.label && (
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
)}
</button>
)
})}
</div>
</div>
{/* Trip-scoped notifications */}
{trips.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
<div className="flex gap-2 mb-2">
<select
value={selectedTripId ?? ''}
onChange={e => setSelectedTripId(Number(e.target.value))}
className="flex-1 px-3 py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{trips.map(trip => (
<option key={trip.id} value={trip.id}>{trip.title}</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
onClick={() => selectedTripId && send('Simple → Trip', {
type: 'simple',
scope: 'trip',
target: selectedTripId,
title_key: 'notifications.test.tripTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.tripText',
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
})}
disabled={sending !== null || !selectedTripId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#8b5cf620', color: '#8b5cf6' }}>
<Send className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple Trip Members</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · trip</p>
</div>
</button>
<button
onClick={() => selectedTripId && send('Navigate → Trip', {
type: 'navigate',
scope: 'trip',
target: selectedTripId,
title_key: 'notifications.test.tripTitle',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.tripText',
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
navigate_text_key: 'notifications.test.goThere',
navigate_target: `/trips/${selectedTripId}`,
})}
disabled={sending !== null || !selectedTripId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
<ArrowRight className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate Trip Members</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
</div>
</button>
</div>
</div>
)}
{/* User-scoped notifications */}
{users.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
<div className="flex gap-2 mb-2">
<select
value={selectedUserId ?? ''}
onChange={e => setSelectedUserId(Number(e.target.value))}
className="flex-1 px-3 py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{users.map(u => (
<option key={u.id} value={u.id}>{u.username} ({u.email})</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'simple',
scope: 'user',
target: selectedUserId,
title_key: 'notifications.test.title',
title_params: { actor: user?.username || 'Admin' },
text_key: 'notifications.test.text',
text_params: {},
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#06b6d420', color: '#06b6d4' }}>
<User className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · user</p>
</div>
</button>
<button
onClick={() => selectedUserId && send(`Boolean → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'boolean',
scope: 'user',
target: selectedUserId,
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: {} },
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#10b98120', color: '#10b981' }}>
<CheckCircle className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Boolean User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>boolean · user</p>
</div>
</button>
<button
onClick={() => selectedUserId && send(`Navigate → ${users.find(u => u.id === selectedUserId)?.username}`, {
type: 'navigate',
scope: 'user',
target: selectedUserId,
title_key: 'notifications.test.navigateTitle',
title_params: {},
text_key: 'notifications.test.navigateText',
text_params: {},
navigate_text_key: 'notifications.test.goThere',
navigate_target: '/dashboard',
})}
disabled={sending !== null || !selectedUserId}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
<ArrowRight className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate User</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · user</p>
</div>
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -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 { useNotificationStore } from '../../store/notificationStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import NotificationItem from '../Notifications/NotificationItem'
export default function NotificationBell(): 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 } = useNotificationStore()
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 (
<div className="relative flex-shrink-0">
<button
onClick={handleOpen}
title={t('notifications.title')}
className="relative p-2 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
style={{
background: '#ef4444',
fontSize: 9,
minWidth: 14,
height: 14,
padding: '0 3px',
lineHeight: 1,
}}
>
{displayCount}
</span>
)}
</button>
{open && ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div
className="rounded-xl shadow-xl border overflow-hidden"
style={{
position: 'fixed',
top: 'var(--nav-h)',
right: 8,
width: 360,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
background: 'var(--bg-card)',
borderColor: 'var(--border-primary)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border-secondary)' }}
>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
style={{ background: '#6366f1', color: '#fff' }}>
{unreadCount}
</span>
)}
</span>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
title={t('notifications.markAllRead')}
className="p-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
{notifications.length > 0 && (
<button
onClick={deleteAll}
title={t('notifications.deleteAll')}
className="p-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
{isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10">
<div className="w-5 h-5 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} />
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
</div>
) : (
notifications.slice(0, 10).map(n => (
<NotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
))
)}
</div>
{/* Footer */}
<button
onClick={handleShowAll}
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
style={{
borderTop: '1px solid var(--border-secondary)',
color: '#6366f1',
background: 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{t('notifications.showAll')}
</button>
</div>
</>,
document.body
)}
</div>
)
}

View File

@@ -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 NotificationBell from './NotificationBell'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
@@ -163,6 +164,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
{/* Notification bell */}
{user && <NotificationBell />}
{/* User menu */}
{user && (
<div className="relative">

View File

@@ -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 { useNotificationStore, InAppNotification } from '../../store/notificationStore'
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 NotificationItem({ 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 } = useNotificationStore()
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 (
<div
className="relative px-4 py-3 transition-colors"
style={{
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
borderBottom: '1px solid var(--border-secondary)',
}}
>
{/* Unread dot */}
{!notification.is_read && (
<div className="absolute left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full" style={{ background: '#6366f1' }} />
)}
<div className="flex gap-3 items-start">
{/* Sender avatar */}
<div className="flex-shrink-0 mt-0.5">
{notification.sender_avatar ? (
<img
src={notification.sender_avatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
>
{notification.sender_username
? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" />
}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
{relativeTime(notification.created_at, locale)}
</span>
{!notification.is_read && (
<button
onClick={() => markRead(notification.id)}
title={t('notifications.markRead')}
className="p-1 rounded transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = '#6366f1' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
className="p-1 rounded transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
{hasUnknownBody ? notification.text_key : bodyText}
</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2">
<button
onClick={() => handleRespond('positive')}
disabled={responding || notification.response !== null}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{
background: notification.response === 'positive'
? '#6366f1'
: notification.response === 'negative'
? (dark ? '#27272a' : '#f1f5f9')
: (dark ? '#27272a' : '#f1f5f9'),
color: notification.response === 'positive'
? '#fff'
: notification.response === 'negative'
? 'var(--text-faint)'
: 'var(--text-secondary)',
opacity: notification.response === 'negative' ? 0.5 : 1,
cursor: notification.response !== null || responding ? 'default' : 'pointer',
}}
>
<Check className="w-3 h-3" />
{t(notification.positive_text_key)}
</button>
<button
onClick={() => handleRespond('negative')}
disabled={responding || notification.response !== null}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{
background: notification.response === 'negative'
? '#ef4444'
: notification.response === 'positive'
? (dark ? '#27272a' : '#f1f5f9')
: (dark ? '#27272a' : '#f1f5f9'),
color: notification.response === 'negative'
? '#fff'
: notification.response === 'positive'
? 'var(--text-faint)'
: 'var(--text-secondary)',
opacity: notification.response === 'positive' ? 0.5 : 1,
cursor: notification.response !== null || responding ? 'default' : 'pointer',
}}
>
<X className="w-3 h-3" />
{t(notification.negative_text_key)}
</button>
</div>
)}
{/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
<button
onClick={handleNavigate}
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9'}
>
<ArrowRight className="w-3 h-3" />
{t(notification.navigate_text_key)}
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { useEffect } from 'react'
import { addListener, removeListener } from '../api/websocket'
import { useNotificationStore } from '../store/notificationStore'
export function useNotificationListener(): void {
const handleNew = useNotificationStore(s => s.handleNewNotification)
const handleUpdated = useNotificationStore(s => s.handleUpdatedNotification)
useEffect(() => {
const listener = (event: Record<string, unknown>) => {
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])
}

View File

@@ -1501,6 +1501,19 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1496,6 +1496,19 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1499,6 +1499,19 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1498,6 +1498,19 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1505,6 +1505,35 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1503,6 +1503,19 @@ const es: Record<string, string> = {
'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

View File

@@ -1497,6 +1497,19 @@ const fr: Record<string, string> = {
'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

View File

@@ -1498,6 +1498,19 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1500,6 +1500,19 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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

View File

@@ -1497,6 +1497,19 @@ const nl: Record<string, string> = {
'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

View File

@@ -1497,6 +1497,19 @@ const ru: Record<string, string> = {
'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

View File

@@ -1497,6 +1497,19 @@ const zh: Record<string, string> = {
'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

View File

@@ -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<string>('users')
@@ -1183,6 +1186,8 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
{activeTab === 'github' && <GitHubPanel />}
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</div>
</div>

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useRef, useState } from 'react'
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
import { useTranslation } from '../i18n'
import { useNotificationStore } from '../store/notificationStore'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
export default function NotificationsPage(): 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 } = useNotificationStore()
const [unreadOnly, setUnreadOnly] = useState(false)
const loaderRef = useRef<HTMLDivElement>(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 (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-2xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 rounded-full text-xs font-medium"
style={{ background: '#6366f1', color: '#fff' }}>
{unreadCount}
</span>
)}
</h1>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
{total} {total === 1 ? 'notification' : 'notifications'}
</p>
</div>
{/* Bulk actions */}
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ background: 'var(--bg-hover)', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<CheckCheck className="w-4 h-4" />
<span className="hidden sm:inline">{t('notifications.markAllRead')}</span>
</button>
)}
{notifications.length > 0 && (
<button
onClick={deleteAll}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-red-500 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">{t('notifications.deleteAll')}</span>
</button>
)}
</div>
</div>
{/* Filter toggle */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setUnreadOnly(false)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: !unreadOnly ? '#6366f1' : 'var(--bg-hover)',
color: !unreadOnly ? '#fff' : 'var(--text-secondary)',
}}
>
{t('notifications.all')}
</button>
<button
onClick={() => setUnreadOnly(true)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: unreadOnly ? '#6366f1' : 'var(--bg-hover)',
color: unreadOnly ? '#fff' : 'var(--text-secondary)',
}}
>
{t('notifications.unreadOnly')}
</button>
</div>
{/* Notification list */}
<div
className="rounded-xl border overflow-hidden"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3">
<Bell className="w-12 h-12" style={{ color: 'var(--text-faint)' }} />
<p className="text-base font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
<p className="text-sm" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
</div>
) : (
displayed.map(n => (
<InAppNotificationItem key={n.id} notification={n} />
))
)}
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <div className="w-5 h-5 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />}
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<AvatarResponse>
deleteAvatar: () => Promise<void>
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<AuthState>((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<AuthState>((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 }),

View File

@@ -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<string, string>
text_key: string
text_params: Record<string, string>
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<InAppNotification, 'title_params' | 'text_params' | 'is_read'> {
title_params: string | Record<string, string>
text_params: string | Record<string, string>
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<void>
fetchUnreadCount: () => Promise<void>
markRead: (id: number) => Promise<void>
markUnread: (id: number) => Promise<void>
markAllRead: () => Promise<void>
deleteNotification: (id: number) => Promise<void>
deleteAll: () => Promise<void>
respondToBoolean: (id: number, response: 'positive' | 'negative') => Promise<void>
handleNewNotification: (notification: RawNotification) => void
handleUpdatedNotification: (notification: RawNotification) => void
}
const PAGE_SIZE = 20
export const useNotificationStore = create<NotificationState>((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),
}))
},
}))

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -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);
`);
}

View File

@@ -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<string, unknown> = {
type: type || 'simple',
scope: scope || 'user',
target: target ?? authReq.user.id,
sender_id: authReq.user.id,
title_key: title_key || 'notifications.test.title',
title_params: title_params || {},
text_key: text_key || 'notifications.test.text',
text_params: text_params || {},
};
if (type === 'boolean') {
input.positive_text_key = positive_text_key || 'notifications.test.accept';
input.negative_text_key = negative_text_key || 'notifications.test.decline';
input.positive_callback = positive_callback || { action: 'test_approve', payload: {} };
input.negative_callback = negative_callback || { action: 'test_deny', payload: {} };
} else if (type === 'navigate') {
input.navigate_text_key = navigate_text_key || 'notifications.test.goThere';
input.navigate_target = navigate_target || '/dashboard';
}
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;

View File

@@ -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;

View File

@@ -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',
};
}

View File

@@ -0,0 +1,22 @@
type ActionHandler = (payload: Record<string, unknown>, respondingUserId: number) => Promise<void>;
const actionRegistry = new Map<string, ActionHandler>();
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 };

View File

@@ -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<string, string>;
text_key: string;
text_params?: Record<string, string>;
}
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<string, unknown> };
negative_callback: { action: string; payload: Record<string, unknown> };
}
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<number>();
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<string, unknown> };
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 };