feat(notifications): add unified multi-channel notification system
Introduces a fully featured notification system with three delivery channels (in-app, email, webhook), normalized per-user/per-event/ per-channel preferences, admin-scoped notifications, scheduled trip reminders and version update alerts. - New notificationService.send() as the single orchestration entry point - In-app notifications with simple/boolean/navigate types and WebSocket push - Per-user preference matrix with normalized notification_channel_preferences table - Admin notification preferences stored globally in app_settings - Migration 69 normalizes legacy notification_preferences table - Scheduler hooks for daily trip reminders and version checks - DevNotificationsPanel for testing in dev mode - All new tests passing, covering dispatch, preferences, migration, boolean responses, resilience, and full API integration (NSVC, NPREF, INOTIF, MIGR, VNOTIF, NROUTE series) - Previous tests passing
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -57,4 +57,5 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
.scannerwork
|
.scannerwork
|
||||||
|
test-data
|
||||||
@@ -43,7 +43,8 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/login" replace />
|
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
||||||
|
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ apiClient.interceptors.response.use(
|
|||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||||
window.location.href = '/login'
|
const currentPath = window.location.pathname + window.location.search
|
||||||
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -197,6 +198,8 @@ export const adminApi = {
|
|||||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||||
sendTestNotification: (data: Record<string, unknown>) =>
|
sendTestNotification: (data: Record<string, unknown>) =>
|
||||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||||
|
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||||
|
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addonsApi = {
|
export const addonsApi = {
|
||||||
@@ -326,9 +329,9 @@ export const shareApi = {
|
|||||||
|
|
||||||
export const notificationsApi = {
|
export const notificationsApi = {
|
||||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||||
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||||
testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data),
|
testWebhook: (url: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inAppNotificationsApi = {
|
export const inAppNotificationsApi = {
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import { adminApi, tripsApi } from '../../api/client'
|
import { adminApi, tripsApi } from '../../api/client'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react'
|
import {
|
||||||
|
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||||
|
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||||
|
Download, MapPin,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
id: number
|
id: number
|
||||||
@@ -37,7 +41,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const send = async (label: string, payload: Record<string, unknown>) => {
|
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||||
setSending(label)
|
setSending(label)
|
||||||
try {
|
try {
|
||||||
await adminApi.sendTestNotification(payload)
|
await adminApi.sendTestNotification(payload)
|
||||||
@@ -49,74 +53,69 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttons = [
|
const selectedTrip = trips.find(t => t.id === selectedTripId)
|
||||||
{
|
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||||
label: 'Simple → Me',
|
const username = user?.username || 'Admin'
|
||||||
icon: Bell,
|
const tripTitle = selectedTrip?.title || 'Test Trip'
|
||||||
color: '#6366f1',
|
|
||||||
payload: {
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
type: 'simple',
|
|
||||||
scope: 'user',
|
const Btn = ({
|
||||||
target: user?.id,
|
id, label, sub, icon: Icon, color, onClick,
|
||||||
title_key: 'notifications.test.title',
|
}: {
|
||||||
title_params: { actor: user?.username || 'Admin' },
|
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||||
text_key: 'notifications.test.text',
|
}) => (
|
||||||
text_params: {},
|
<button
|
||||||
},
|
onClick={onClick}
|
||||||
},
|
disabled={sending !== null}
|
||||||
{
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
|
||||||
label: 'Boolean → Me',
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||||
icon: CheckCircle,
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
color: '#10b981',
|
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||||
payload: {
|
>
|
||||||
type: 'boolean',
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
scope: 'user',
|
style={{ background: `${color}20`, color }}>
|
||||||
target: user?.id,
|
<Icon className="w-4 h-4" />
|
||||||
title_key: 'notifications.test.booleanTitle',
|
</div>
|
||||||
title_params: { actor: user?.username || 'Admin' },
|
<div className="min-w-0 flex-1">
|
||||||
text_key: 'notifications.test.booleanText',
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||||
text_params: {},
|
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
|
||||||
positive_text_key: 'notifications.test.accept',
|
</div>
|
||||||
negative_text_key: 'notifications.test.decline',
|
{sending === id && (
|
||||||
positive_callback: { action: 'test_approve', payload: {} },
|
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||||
negative_callback: { action: 'test_deny', payload: {} },
|
)}
|
||||||
},
|
</button>
|
||||||
},
|
)
|
||||||
{
|
|
||||||
label: 'Navigate → Me',
|
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||||
icon: Navigation,
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
|
||||||
color: '#f59e0b',
|
)
|
||||||
payload: {
|
|
||||||
type: 'navigate',
|
const TripSelector = () => (
|
||||||
scope: 'user',
|
<select
|
||||||
target: user?.id,
|
value={selectedTripId ?? ''}
|
||||||
title_key: 'notifications.test.navigateTitle',
|
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||||
title_params: {},
|
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||||
text_key: 'notifications.test.navigateText',
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||||
text_params: {},
|
>
|
||||||
navigate_text_key: 'notifications.test.goThere',
|
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||||
navigate_target: '/dashboard',
|
</select>
|
||||||
},
|
)
|
||||||
},
|
|
||||||
{
|
const UserSelector = () => (
|
||||||
label: 'Simple → Admins',
|
<select
|
||||||
icon: Zap,
|
value={selectedUserId ?? ''}
|
||||||
color: '#ef4444',
|
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||||
payload: {
|
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||||
type: 'simple',
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||||
scope: 'admin',
|
>
|
||||||
target: 0,
|
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||||
title_key: 'notifications.test.adminTitle',
|
</select>
|
||||||
title_params: {},
|
)
|
||||||
text_key: 'notifications.test.adminText',
|
|
||||||
text_params: { actor: user?.username || 'Admin' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||||
DEV ONLY
|
DEV ONLY
|
||||||
</div>
|
</div>
|
||||||
@@ -125,219 +124,162 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||||
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Quick-fire buttons */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
|
<SectionTitle>Type Testing</SectionTitle>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Test how each in-app notification type renders, sent to yourself.
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{buttons.map(btn => {
|
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||||
const Icon = btn.icon
|
onClick={() => fire('simple-me', {
|
||||||
return (
|
event: 'test_simple',
|
||||||
<button
|
scope: 'user',
|
||||||
key={btn.label}
|
targetId: user?.id,
|
||||||
onClick={() => send(btn.label, btn.payload)}
|
params: {},
|
||||||
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)' }}
|
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onClick={() => fire('boolean-me', {
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
event: 'test_boolean',
|
||||||
>
|
scope: 'user',
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
targetId: user?.id,
|
||||||
style={{ background: `${btn.color}20`, color: btn.color }}>
|
params: {},
|
||||||
<Icon className="w-4 h-4" />
|
inApp: {
|
||||||
</div>
|
type: 'boolean',
|
||||||
<div className="min-w-0">
|
positiveCallback: { action: 'test_approve', payload: {} },
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
|
negativeCallback: { action: 'test_deny', payload: {} },
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
|
},
|
||||||
{btn.payload.type} · {btn.payload.scope}
|
})}
|
||||||
</p>
|
/>
|
||||||
</div>
|
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||||
{sending === btn.label && (
|
onClick={() => fire('navigate-me', {
|
||||||
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
event: 'test_navigate',
|
||||||
)}
|
scope: 'user',
|
||||||
</button>
|
targetId: user?.id,
|
||||||
)
|
params: {},
|
||||||
})}
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||||
|
onClick={() => fire('simple-admins', {
|
||||||
|
event: 'test_simple',
|
||||||
|
scope: 'admin',
|
||||||
|
targetId: 0,
|
||||||
|
params: {},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trip-scoped notifications */}
|
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||||
{trips.length > 0 && (
|
{trips.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
|
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||||
<div className="flex gap-2 mb-2">
|
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
<select
|
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||||
value={selectedTripId ?? ''}
|
</p>
|
||||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
<TripSelector />
|
||||||
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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
<button
|
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||||
onClick={() => selectedTripId && send('Simple → Trip', {
|
onClick={() => selectedTripId && fire('booking_change', {
|
||||||
type: 'simple',
|
event: 'booking_change',
|
||||||
scope: 'trip',
|
scope: 'trip',
|
||||||
target: selectedTripId,
|
targetId: selectedTripId,
|
||||||
title_key: 'notifications.test.tripTitle',
|
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||||
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"
|
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
event: 'trip_reminder',
|
||||||
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',
|
scope: 'trip',
|
||||||
target: selectedTripId,
|
targetId: selectedTripId,
|
||||||
title_key: 'notifications.test.tripTitle',
|
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||||
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"
|
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
onClick={() => selectedTripId && fire('photos_shared', {
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
event: 'photos_shared',
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
scope: 'trip',
|
||||||
>
|
targetId: selectedTripId,
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
})}
|
||||||
<ArrowRight className="w-4 h-4" />
|
/>
|
||||||
</div>
|
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||||
<div>
|
onClick={() => selectedTripId && fire('collab_message', {
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → Trip Members</p>
|
event: 'collab_message',
|
||||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
|
scope: 'trip',
|
||||||
</div>
|
targetId: selectedTripId,
|
||||||
</button>
|
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||||
|
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||||
|
event: 'packing_tagged',
|
||||||
|
scope: 'trip',
|
||||||
|
targetId: selectedTripId,
|
||||||
|
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User-scoped notifications */}
|
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||||
{users.length > 0 && (
|
{users.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
|
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||||
<div className="flex gap-2 mb-2">
|
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
<select
|
Fires each user event to the selected recipient.
|
||||||
value={selectedUserId ?? ''}
|
</p>
|
||||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
<UserSelector />
|
||||||
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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
<button
|
<Btn
|
||||||
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
id={`trip_invite-${selectedUserId}`}
|
||||||
type: 'simple',
|
label="trip_invite"
|
||||||
|
sub="navigate · user"
|
||||||
|
icon={UserPlus}
|
||||||
|
color="#06b6d4"
|
||||||
|
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||||
|
event: 'trip_invite',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
target: selectedUserId,
|
targetId: selectedUserId,
|
||||||
title_key: 'notifications.test.title',
|
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
|
||||||
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"
|
<Btn
|
||||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
id={`vacay_invite-${selectedUserId}`}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
label="vacay_invite"
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
sub="navigate · user"
|
||||||
>
|
icon={MapPin}
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
color="#f97316"
|
||||||
style={{ background: '#06b6d420', color: '#06b6d4' }}>
|
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||||
<User className="w-4 h-4" />
|
event: 'vacay_invite',
|
||||||
</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',
|
scope: 'user',
|
||||||
target: selectedUserId,
|
targetId: selectedUserId,
|
||||||
title_key: 'notifications.test.booleanTitle',
|
params: { actor: username, planId: '1' },
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Fires to all admin users.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||||
|
onClick={() => fire('version_available', {
|
||||||
|
event: 'version_available',
|
||||||
|
scope: 'admin',
|
||||||
|
targetId: 0,
|
||||||
|
params: { version: '9.9.9-test' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -163,23 +163,44 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Packing list: assignments',
|
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||||
'settings.notifyWebhook': 'Webhook notifications',
|
'settings.notifyWebhook': 'Webhook notifications',
|
||||||
|
'settings.notifyVersionAvailable': 'New version available',
|
||||||
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
|
'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
|
||||||
|
'settings.webhookUrl.label': 'Webhook URL',
|
||||||
|
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||||
|
'settings.webhookUrl.hint': 'Enter your Discord, Slack, or custom webhook URL to receive notifications.',
|
||||||
|
'settings.webhookUrl.save': 'Save',
|
||||||
|
'settings.webhookUrl.saved': 'Webhook URL saved',
|
||||||
|
'settings.webhookUrl.test': 'Test',
|
||||||
|
'settings.webhookUrl.testSuccess': 'Test webhook sent successfully',
|
||||||
|
'settings.webhookUrl.testFailed': 'Test webhook failed',
|
||||||
'admin.notifications.title': 'Notifications',
|
'admin.notifications.title': 'Notifications',
|
||||||
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||||
'admin.notifications.none': 'Disabled',
|
'admin.notifications.none': 'Disabled',
|
||||||
'admin.notifications.email': 'Email (SMTP)',
|
'admin.notifications.email': 'Email (SMTP)',
|
||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
'admin.notifications.events': 'Notification Events',
|
|
||||||
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
|
|
||||||
'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
|
|
||||||
'admin.notifications.save': 'Save notification settings',
|
'admin.notifications.save': 'Save notification settings',
|
||||||
'admin.notifications.saved': 'Notification settings saved',
|
'admin.notifications.saved': 'Notification settings saved',
|
||||||
'admin.notifications.testWebhook': 'Send test webhook',
|
'admin.notifications.testWebhook': 'Send test webhook',
|
||||||
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
||||||
'admin.notifications.testWebhookFailed': 'Test webhook failed',
|
'admin.notifications.testWebhookFailed': 'Test webhook failed',
|
||||||
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
|
'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.',
|
||||||
|
'admin.notifications.adminWebhookPanel.title': 'Admin Webhook',
|
||||||
|
'admin.notifications.adminWebhookPanel.hint': 'This webhook is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user webhooks and always fires when set.',
|
||||||
|
'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL saved',
|
||||||
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully',
|
||||||
|
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed',
|
||||||
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured',
|
||||||
|
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
||||||
'admin.smtp.title': 'Email & Notifications',
|
'admin.smtp.title': 'Email & Notifications',
|
||||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||||
'admin.smtp.testButton': 'Send test email',
|
'admin.smtp.testButton': 'Send test email',
|
||||||
'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
|
'admin.webhook.hint': 'Allow users to configure their own webhook URLs for notifications (Discord, Slack, etc.).',
|
||||||
'admin.smtp.testSuccess': 'Test email sent successfully',
|
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||||
'admin.smtp.testFailed': 'Test email failed',
|
'admin.smtp.testFailed': 'Test email failed',
|
||||||
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
||||||
@@ -383,6 +404,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.users': 'Users',
|
'admin.tabs.users': 'Users',
|
||||||
'admin.tabs.categories': 'Categories',
|
'admin.tabs.categories': 'Categories',
|
||||||
'admin.tabs.backup': 'Backup',
|
'admin.tabs.backup': 'Backup',
|
||||||
|
'admin.tabs.notifications': 'Notifications',
|
||||||
|
'admin.tabs.notificationChannels': 'Notification Channels',
|
||||||
|
'admin.tabs.adminNotifications': 'Admin Notifications',
|
||||||
'admin.tabs.audit': 'Audit log',
|
'admin.tabs.audit': 'Audit log',
|
||||||
'admin.stats.users': 'Users',
|
'admin.stats.users': 'Users',
|
||||||
'admin.stats.trips': 'Trips',
|
'admin.stats.trips': 'Trips',
|
||||||
@@ -1551,6 +1575,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'notifications.system': 'System',
|
'notifications.system': 'System',
|
||||||
|
|
||||||
// Notification test keys (dev only)
|
// Notification test keys (dev only)
|
||||||
|
'notifications.versionAvailable.title': 'Update Available',
|
||||||
|
'notifications.versionAvailable.text': 'TREK {version} is now available.',
|
||||||
|
'notifications.versionAvailable.button': 'View Details',
|
||||||
'notifications.test.title': 'Test notification from {actor}',
|
'notifications.test.title': 'Test notification from {actor}',
|
||||||
'notifications.test.text': 'This is a simple test notification.',
|
'notifications.test.text': 'This is a simple test notification.',
|
||||||
'notifications.test.booleanTitle': '{actor} asks for your approval',
|
'notifications.test.booleanTitle': '{actor} asks for your approval',
|
||||||
@@ -1598,6 +1625,43 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.detail.priority': 'Priority',
|
'todo.detail.priority': 'Priority',
|
||||||
'todo.detail.noPriority': 'None',
|
'todo.detail.noPriority': 'None',
|
||||||
'todo.detail.create': 'Create task',
|
'todo.detail.create': 'Create task',
|
||||||
|
|
||||||
|
// Notifications — dev test events
|
||||||
|
'notif.test.title': '[Test] Notification',
|
||||||
|
'notif.test.simple.text': 'This is a simple test notification.',
|
||||||
|
'notif.test.boolean.text': 'Do you accept this test notification?',
|
||||||
|
'notif.test.navigate.text': 'Click below to navigate to the dashboard.',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
'notif.trip_invite.title': 'Trip Invitation',
|
||||||
|
'notif.trip_invite.text': '{actor} invited you to {trip}',
|
||||||
|
'notif.booking_change.title': 'Booking Updated',
|
||||||
|
'notif.booking_change.text': '{actor} updated a booking in {trip}',
|
||||||
|
'notif.trip_reminder.title': 'Trip Reminder',
|
||||||
|
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
|
||||||
|
'notif.vacay_invite.title': 'Vacay Fusion Invite',
|
||||||
|
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
|
||||||
|
'notif.photos_shared.title': 'Photos Shared',
|
||||||
|
'notif.photos_shared.text': '{actor} shared {count} photo(s) in {trip}',
|
||||||
|
'notif.collab_message.title': 'New Message',
|
||||||
|
'notif.collab_message.text': '{actor} sent a message in {trip}',
|
||||||
|
'notif.packing_tagged.title': 'Packing Assignment',
|
||||||
|
'notif.packing_tagged.text': '{actor} assigned you to {category} in {trip}',
|
||||||
|
'notif.version_available.title': 'New Version Available',
|
||||||
|
'notif.version_available.text': 'TREK {version} is now available',
|
||||||
|
'notif.action.view_trip': 'View Trip',
|
||||||
|
'notif.action.view_collab': 'View Messages',
|
||||||
|
'notif.action.view_packing': 'View Packing',
|
||||||
|
'notif.action.view_photos': 'View Photos',
|
||||||
|
'notif.action.view_vacay': 'View Vacay',
|
||||||
|
'notif.action.view_admin': 'Go to Admin',
|
||||||
|
'notif.action.view': 'View',
|
||||||
|
'notif.action.accept': 'Accept',
|
||||||
|
'notif.action.decline': 'Decline',
|
||||||
|
'notif.generic.title': 'Notification',
|
||||||
|
'notif.generic.text': 'You have a new notification',
|
||||||
|
'notif.dev.unknown_event.title': '[DEV] Unknown Event',
|
||||||
|
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default en
|
export default en
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -149,6 +149,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
|
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
|
||||||
'settings.notifyPackingTagged': 'Lista pakowania: przypisania',
|
'settings.notifyPackingTagged': 'Lista pakowania: przypisania',
|
||||||
'settings.notifyWebhook': 'Powiadomienia Webhook',
|
'settings.notifyWebhook': 'Powiadomienia Webhook',
|
||||||
|
'settings.notifyVersionAvailable': 'New version available',
|
||||||
'admin.smtp.title': 'E-maile i powiadomienia',
|
'admin.smtp.title': 'E-maile i powiadomienia',
|
||||||
'admin.smtp.hint': 'Konfiguracja SMTP dla powiadomień e-mail. Opcjonalnie: URL Webhooka dla Discorda, Slacka, itp.',
|
'admin.smtp.hint': 'Konfiguracja SMTP dla powiadomień e-mail. Opcjonalnie: URL Webhooka dla Discorda, Slacka, itp.',
|
||||||
'admin.smtp.testButton': 'Wyślij testowego e-maila',
|
'admin.smtp.testButton': 'Wyślij testowego e-maila',
|
||||||
@@ -349,6 +350,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.users': 'Użytkownicy',
|
'admin.tabs.users': 'Użytkownicy',
|
||||||
'admin.tabs.categories': 'Kategorie',
|
'admin.tabs.categories': 'Kategorie',
|
||||||
'admin.tabs.backup': 'Backupy',
|
'admin.tabs.backup': 'Backupy',
|
||||||
|
'admin.tabs.notifications': 'Notifications',
|
||||||
|
'admin.tabs.notificationChannels': 'Kanały powiadomień',
|
||||||
|
'admin.tabs.adminNotifications': 'Powiadomienia admina',
|
||||||
'admin.tabs.audit': 'Aktywność',
|
'admin.tabs.audit': 'Aktywność',
|
||||||
'admin.stats.users': 'Użytkownicy',
|
'admin.stats.users': 'Użytkownicy',
|
||||||
'admin.stats.trips': 'Podróże',
|
'admin.stats.trips': 'Podróże',
|
||||||
@@ -1438,8 +1442,31 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.testWebhook': 'Wyślij testowy webhook',
|
'admin.notifications.testWebhook': 'Wyślij testowy webhook',
|
||||||
'admin.notifications.testWebhookSuccess': 'Testowy webhook wysłany pomyślnie',
|
'admin.notifications.testWebhookSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||||
'admin.notifications.testWebhookFailed': 'Testowy webhook nie powiódł się',
|
'admin.notifications.testWebhookFailed': 'Testowy webhook nie powiódł się',
|
||||||
'admin.webhook.hint': 'Wysyłaj powiadomienia do zewnętrznego webhooka.',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
|
'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.',
|
||||||
|
'admin.notifications.adminWebhookPanel.title': 'Webhook admina',
|
||||||
|
'admin.notifications.adminWebhookPanel.hint': 'Ten webhook służy wyłącznie do powiadomień admina (np. alertów o nowych wersjach). Jest niezależny od webhooków użytkowników i wysyła automatycznie, gdy URL jest skonfigurowany.',
|
||||||
|
'admin.notifications.adminWebhookPanel.saved': 'URL webhooka admina zapisany',
|
||||||
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||||
|
'admin.notifications.adminWebhookPanel.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||||
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany',
|
||||||
|
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
|
||||||
|
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
|
||||||
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
||||||
|
'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
|
||||||
|
'settings.webhookUrl.label': 'URL webhooka',
|
||||||
|
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||||
|
'settings.webhookUrl.hint': 'Wprowadź adres URL webhooka Discord, Slack lub własnego, aby otrzymywać powiadomienia.',
|
||||||
|
'settings.webhookUrl.save': 'Zapisz',
|
||||||
|
'settings.webhookUrl.saved': 'URL webhooka zapisany',
|
||||||
|
'settings.webhookUrl.test': 'Test',
|
||||||
|
'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||||
|
'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||||
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
|
'settings.notificationPreferences.email': 'Email',
|
||||||
'settings.notificationsActive': 'Aktywny kanał',
|
'settings.notificationsActive': 'Aktywny kanał',
|
||||||
'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.',
|
'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.',
|
||||||
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
|
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
|
||||||
@@ -1543,6 +1570,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'notifications.markUnread': 'Oznacz jako nieprzeczytane',
|
'notifications.markUnread': 'Oznacz jako nieprzeczytane',
|
||||||
'notifications.delete': 'Usuń',
|
'notifications.delete': 'Usuń',
|
||||||
'notifications.system': 'System',
|
'notifications.system': 'System',
|
||||||
|
'notifications.versionAvailable.title': 'Update Available',
|
||||||
|
'notifications.versionAvailable.text': 'TREK {version} is now available.',
|
||||||
|
'notifications.versionAvailable.button': 'View Details',
|
||||||
'notifications.test.title': 'Testowe powiadomienie od {actor}',
|
'notifications.test.title': 'Testowe powiadomienie od {actor}',
|
||||||
'notifications.test.text': 'To jest powiadomienie testowe.',
|
'notifications.test.text': 'To jest powiadomienie testowe.',
|
||||||
'notifications.test.booleanTitle': '{actor} prosi o akceptację',
|
'notifications.test.booleanTitle': '{actor} prosi o akceptację',
|
||||||
@@ -1590,6 +1620,43 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.detail.priority': 'Priority',
|
'todo.detail.priority': 'Priority',
|
||||||
'todo.detail.noPriority': 'None',
|
'todo.detail.noPriority': 'None',
|
||||||
'todo.sortByPrio': 'Priority',
|
'todo.sortByPrio': 'Priority',
|
||||||
|
|
||||||
|
// Notifications — dev test events
|
||||||
|
'notif.test.title': '[Test] Notification',
|
||||||
|
'notif.test.simple.text': 'This is a simple test notification.',
|
||||||
|
'notif.test.boolean.text': 'Do you accept this test notification?',
|
||||||
|
'notif.test.navigate.text': 'Click below to navigate to the dashboard.',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
'notif.trip_invite.title': 'Zaproszenie do podróży',
|
||||||
|
'notif.trip_invite.text': '{actor} zaprosił Cię do {trip}',
|
||||||
|
'notif.booking_change.title': 'Rezerwacja zaktualizowana',
|
||||||
|
'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}',
|
||||||
|
'notif.trip_reminder.title': 'Przypomnienie o podróży',
|
||||||
|
'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!',
|
||||||
|
'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion',
|
||||||
|
'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych',
|
||||||
|
'notif.photos_shared.title': 'Zdjęcia udostępnione',
|
||||||
|
'notif.photos_shared.text': '{actor} udostępnił {count} zdjęcie/zdjęcia w {trip}',
|
||||||
|
'notif.collab_message.title': 'Nowa wiadomość',
|
||||||
|
'notif.collab_message.text': '{actor} wysłał wiadomość w {trip}',
|
||||||
|
'notif.packing_tagged.title': 'Zadanie pakowania',
|
||||||
|
'notif.packing_tagged.text': '{actor} przypisał Cię do {category} w {trip}',
|
||||||
|
'notif.version_available.title': 'Nowa wersja dostępna',
|
||||||
|
'notif.version_available.text': 'TREK {version} jest teraz dostępny',
|
||||||
|
'notif.action.view_trip': 'Zobacz podróż',
|
||||||
|
'notif.action.view_collab': 'Zobacz wiadomości',
|
||||||
|
'notif.action.view_packing': 'Zobacz pakowanie',
|
||||||
|
'notif.action.view_photos': 'Zobacz zdjęcia',
|
||||||
|
'notif.action.view_vacay': 'Zobacz Vacay',
|
||||||
|
'notif.action.view_admin': 'Przejdź do admina',
|
||||||
|
'notif.action.view': 'Zobacz',
|
||||||
|
'notif.action.accept': 'Akceptuj',
|
||||||
|
'notif.action.decline': 'Odrzuć',
|
||||||
|
'notif.generic.title': 'Powiadomienie',
|
||||||
|
'notif.generic.text': 'Masz nowe powiadomienie',
|
||||||
|
'notif.dev.unknown_event.title': '[DEV] Unknown Event',
|
||||||
|
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default pl
|
export default pl
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,107 @@ interface UpdateInfo {
|
|||||||
is_docker?: boolean
|
is_docker?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
|
||||||
|
version_available: 'settings.notifyVersionAvailable',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||||
|
inapp: 'settings.notificationPreferences.inapp',
|
||||||
|
email: 'settings.notificationPreferences.email',
|
||||||
|
webhook: 'settings.notificationPreferences.webhook',
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
|
||||||
|
const [matrix, setMatrix] = useState<any>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||||
|
|
||||||
|
const visibleChannels = (['inapp', 'email', 'webhook'] as const).filter(ch => {
|
||||||
|
if (!matrix.available_channels[ch]) return false
|
||||||
|
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = async (eventType: string, channel: string) => {
|
||||||
|
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||||
|
const updated = { ...matrix.preferences, [eventType]: { ...matrix.preferences[eventType], [channel]: !current } }
|
||||||
|
setMatrix((m: any) => m ? { ...m, preferences: updated } : m)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await adminApi.updateNotificationPreferences(updated)
|
||||||
|
} catch {
|
||||||
|
setMatrix((m: any) => m ? { ...m, preferences: matrix.preferences } : m)
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matrix.event_types.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('settings.notificationPreferences.noChannels')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.tabs.adminNotifications')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||||
|
{/* Header row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||||
|
<span />
|
||||||
|
{visibleChannels.map(ch => (
|
||||||
|
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
{t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Event rows */}
|
||||||
|
{matrix.event_types.map((eventType: string) => {
|
||||||
|
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||||
|
return (
|
||||||
|
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
|
{t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||||
|
</span>
|
||||||
|
{visibleChannels.map(ch => {
|
||||||
|
if (!implementedForEvent.includes(ch)) {
|
||||||
|
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||||
|
}
|
||||||
|
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||||
|
return (
|
||||||
|
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(eventType, ch)}
|
||||||
|
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors flex-shrink-0"
|
||||||
|
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: isOn ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage(): React.ReactElement {
|
export default function AdminPage(): React.ReactElement {
|
||||||
const { demoMode, serverTimezone } = useAuthStore()
|
const { demoMode, serverTimezone } = useAuthStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
@@ -68,6 +169,8 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
{ id: 'config', label: t('admin.tabs.config') },
|
{ id: 'config', label: t('admin.tabs.config') },
|
||||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||||
|
{ id: 'notification-channels', label: t('admin.tabs.notificationChannels') },
|
||||||
|
{ id: 'admin-notifications', label: t('admin.tabs.adminNotifications') },
|
||||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
||||||
@@ -969,189 +1072,6 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Notifications — exclusive channel selector */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
|
||||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.title')}</h2>
|
|
||||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
{/* Channel selector */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['none', 'email', 'webhook'] as const).map(ch => {
|
|
||||||
const active = (smtpValues.notification_channel || 'none') === ch
|
|
||||||
const labels: Record<string, string> = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') }
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={ch}
|
|
||||||
onClick={() => setSmtpValues(prev => ({ ...prev, notification_channel: ch }))}
|
|
||||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors border ${active ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50'}`}
|
|
||||||
>
|
|
||||||
{labels[ch]}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notification event toggles — shown when any channel is active */}
|
|
||||||
{(smtpValues.notification_channel || 'none') !== 'none' && (() => {
|
|
||||||
const ch = smtpValues.notification_channel || 'none'
|
|
||||||
const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false
|
|
||||||
return (
|
|
||||||
<div className={`space-y-2 pt-2 border-t border-slate-100 ${!configValid ? 'opacity-50 pointer-events-none' : ''}`}>
|
|
||||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
|
|
||||||
{!configValid && (
|
|
||||||
<p className="text-[10px] text-amber-600 mb-3">{t('admin.notifications.configureFirst')}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
|
|
||||||
{[
|
|
||||||
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
|
||||||
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
|
|
||||||
{ key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') },
|
|
||||||
{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') },
|
|
||||||
{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') },
|
|
||||||
{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') },
|
|
||||||
{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') },
|
|
||||||
].map(opt => {
|
|
||||||
const isOn = (smtpValues[opt.key] ?? 'true') !== 'false'
|
|
||||||
return (
|
|
||||||
<div key={opt.key} className="flex items-center justify-between py-1">
|
|
||||||
<span className="text-sm text-slate-700">{opt.label}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const newVal = isOn ? 'false' : 'true'
|
|
||||||
setSmtpValues(prev => ({ ...prev, [opt.key]: newVal }))
|
|
||||||
}}
|
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
||||||
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
|
||||||
>
|
|
||||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
|
||||||
style={{ transform: isOn ? 'translateX(20px)' : 'translateX(0)' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Email (SMTP) settings — shown when email channel is active */}
|
|
||||||
{(smtpValues.notification_channel || 'none') === 'email' && (
|
|
||||||
<div className="space-y-3 pt-2 border-t border-slate-100">
|
|
||||||
<p className="text-xs text-slate-400">{t('admin.smtp.hint')}</p>
|
|
||||||
{smtpLoaded && [
|
|
||||||
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
|
||||||
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
|
||||||
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
|
||||||
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
|
||||||
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
|
||||||
].map(field => (
|
|
||||||
<div key={field.key}>
|
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
|
||||||
<input
|
|
||||||
type={field.type || 'text'}
|
|
||||||
value={smtpValues[field.key] || ''}
|
|
||||||
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
|
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => {
|
|
||||||
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
|
||||||
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
|
||||||
}}
|
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
||||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
|
||||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
|
||||||
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Webhook settings — shown when webhook channel is active */}
|
|
||||||
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
|
||||||
<div className="space-y-3 pt-2 border-t border-slate-100">
|
|
||||||
<p className="text-xs text-slate-400">{t('admin.webhook.hint')}</p>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={smtpValues.notification_webhook_url || ''}
|
|
||||||
onChange={e => setSmtpValues(prev => ({ ...prev, notification_webhook_url: e.target.value }))}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-slate-400 mt-1">TREK will POST JSON with event, title, body, and timestamp to this URL.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save + Test buttons */}
|
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
const notifKeys = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged']
|
|
||||||
const payload: Record<string, string> = {}
|
|
||||||
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
|
||||||
try {
|
|
||||||
await authApi.updateAppSettings(payload)
|
|
||||||
toast.success(t('admin.notifications.saved'))
|
|
||||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
|
||||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch { toast.error(t('common.error')) }
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
{(smtpValues.notification_channel || 'none') === 'email' && (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
|
||||||
const payload: Record<string, string> = {}
|
|
||||||
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
|
||||||
await authApi.updateAppSettings(payload).catch(() => {})
|
|
||||||
try {
|
|
||||||
const result = await notificationsApi.testSmtp()
|
|
||||||
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
|
||||||
else toast.error(result.error || t('admin.smtp.testFailed'))
|
|
||||||
} catch { toast.error(t('admin.smtp.testFailed')) }
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
|
||||||
>
|
|
||||||
{t('admin.smtp.testButton')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (smtpValues.notification_webhook_url) {
|
|
||||||
await authApi.updateAppSettings({ notification_webhook_url: smtpValues.notification_webhook_url }).catch(() => {})
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await notificationsApi.testWebhook()
|
|
||||||
if (result.success) toast.success(t('admin.notifications.testWebhookSuccess'))
|
|
||||||
else toast.error(result.error || t('admin.notifications.testWebhookFailed'))
|
|
||||||
} catch { toast.error(t('admin.notifications.testWebhookFailed')) }
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
|
||||||
>
|
|
||||||
{t('admin.notifications.testWebhook')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
|
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
|
||||||
@@ -1179,6 +1099,208 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'notification-channels' && (() => {
|
||||||
|
// Derive active channels from smtpValues.notification_channels (plural)
|
||||||
|
// with fallback to notification_channel (singular) for existing installs
|
||||||
|
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
|
||||||
|
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
|
||||||
|
const emailActive = activeChans.includes('email')
|
||||||
|
const webhookActive = activeChans.includes('webhook')
|
||||||
|
|
||||||
|
const setChannels = async (email: boolean, webhook: boolean) => {
|
||||||
|
const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none'
|
||||||
|
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
|
||||||
|
try {
|
||||||
|
await authApi.updateAppSettings({ notification_channels: chans })
|
||||||
|
} catch {
|
||||||
|
// Revert state on failure
|
||||||
|
const reverted = [emailActive && 'email', webhookActive && 'webhook'].filter(Boolean).join(',') || 'none'
|
||||||
|
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpConfigured = !!(smtpValues.smtp_host?.trim())
|
||||||
|
const saveNotifications = async () => {
|
||||||
|
// Saves credentials only — channel activation is auto-saved by the toggle
|
||||||
|
const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||||
|
const payload: Record<string, string> = {}
|
||||||
|
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
||||||
|
try {
|
||||||
|
await authApi.updateAppSettings(payload)
|
||||||
|
toast.success(t('admin.notifications.saved'))
|
||||||
|
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||||
|
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||||
|
}).catch(() => {})
|
||||||
|
} catch { toast.error(t('common.error')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Email Panel */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.notifications.emailPanel.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannels(!emailActive, webhookActive)}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||||
|
style={{ background: emailActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: emailActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={`p-6 space-y-3 ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
|
{smtpLoaded && [
|
||||||
|
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
||||||
|
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
||||||
|
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
||||||
|
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||||
|
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||||
|
].map(field => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||||
|
<input
|
||||||
|
type={field.type || 'text'}
|
||||||
|
value={smtpValues[field.key] || ''}
|
||||||
|
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => {
|
||||||
|
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||||
|
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||||
|
}}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
|
||||||
|
<button onClick={saveNotifications}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
|
||||||
|
<Save className="w-4 h-4" />{t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||||
|
const payload: Record<string, string> = {}
|
||||||
|
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
||||||
|
await authApi.updateAppSettings(payload).catch(() => {})
|
||||||
|
try {
|
||||||
|
const result = await notificationsApi.testSmtp()
|
||||||
|
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||||
|
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||||
|
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||||
|
}}
|
||||||
|
disabled={!smtpConfigured}
|
||||||
|
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('admin.smtp.testButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Webhook Panel */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.notifications.webhookPanel.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannels(emailActive, !webhookActive)}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||||
|
style={{ background: webhookActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: webhookActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In-App Panel */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.notifications.inappPanel.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.inappPanel.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0"
|
||||||
|
style={{ background: 'var(--text-primary)', opacity: 0.5, cursor: 'not-allowed' }}>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: 'translateX(20px)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Webhook Panel */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.notifications.adminWebhookPanel.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminWebhookPanel.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
{smtpLoaded && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminWebhookPanel.title')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={smtpValues.admin_webhook_url || ''}
|
||||||
|
onChange={e => setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' })
|
||||||
|
toast.success(t('admin.notifications.adminWebhookPanel.saved'))
|
||||||
|
} catch { toast.error(t('common.error')) }
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
|
||||||
|
<Save className="w-4 h-4" />{t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!smtpValues.admin_webhook_url) return
|
||||||
|
try {
|
||||||
|
await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url }).catch(() => {})
|
||||||
|
const result = await notificationsApi.testWebhook(smtpValues.admin_webhook_url)
|
||||||
|
if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
|
||||||
|
else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
|
||||||
|
} catch { toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) }
|
||||||
|
}}
|
||||||
|
disabled={!smtpValues.admin_webhook_url?.trim()}
|
||||||
|
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('admin.smtp.testButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{activeTab === 'admin-notifications' && <AdminNotificationsPanel t={t} toast={toast} />}
|
||||||
|
|
||||||
{activeTab === 'backup' && <BackupPanel />}
|
{activeTab === 'backup' && <BackupPanel />}
|
||||||
|
|
||||||
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
|
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
@@ -34,6 +34,16 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
const { setLanguageLocal } = useSettingsStore()
|
const { setLanguageLocal } = useSettingsStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const redirectTarget = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const redirect = params.get('redirect')
|
||||||
|
// Only allow relative paths starting with / to prevent open redirect attacks
|
||||||
|
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) {
|
||||||
|
return redirect
|
||||||
|
}
|
||||||
|
return '/dashboard'
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
@@ -99,7 +109,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await demoLogin()
|
await demoLogin()
|
||||||
setShowTakeoff(true)
|
setShowTakeoff(true)
|
||||||
setTimeout(() => navigate('/dashboard'), 2600)
|
setTimeout(() => navigate(redirectTarget), 2600)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : t('login.demoFailed'))
|
setError(err instanceof Error ? err.message : t('login.demoFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -128,7 +138,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
|
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
|
||||||
await loadUser({ silent: true })
|
await loadUser({ silent: true })
|
||||||
setShowTakeoff(true)
|
setShowTakeoff(true)
|
||||||
setTimeout(() => navigate('/dashboard'), 2600)
|
setTimeout(() => navigate(redirectTarget), 2600)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mode === 'login' && mfaStep) {
|
if (mode === 'login' && mfaStep) {
|
||||||
@@ -145,7 +155,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setShowTakeoff(true)
|
setShowTakeoff(true)
|
||||||
setTimeout(() => navigate('/dashboard'), 2600)
|
setTimeout(() => navigate(redirectTarget), 2600)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mode === 'register') {
|
if (mode === 'register') {
|
||||||
@@ -169,7 +179,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowTakeoff(true)
|
setShowTakeoff(true)
|
||||||
setTimeout(() => navigate('/dashboard'), 2600)
|
setTimeout(() => navigate(redirectTarget), 2600)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(getApiErrorMessage(err, t('login.error')))
|
setError(getApiErrorMessage(err, t('login.error')))
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Navbar from '../components/Layout/Navbar'
|
|||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check, Info } from 'lucide-react'
|
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check, Info } from 'lucide-react'
|
||||||
import { authApi, adminApi } from '../api/client'
|
import { authApi, adminApi, notificationsApi, settingsApi } from '../api/client'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import { useAddonStore } from '../store/addonStore'
|
import { useAddonStore } from '../store/addonStore'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
@@ -75,37 +75,173 @@ function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PreferencesMatrix {
|
||||||
|
preferences: Record<string, Record<string, boolean>>
|
||||||
|
available_channels: { email: boolean; webhook: boolean; inapp: boolean }
|
||||||
|
event_types: string[]
|
||||||
|
implemented_combos: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||||
|
email: 'settings.notificationPreferences.email',
|
||||||
|
webhook: 'settings.notificationPreferences.webhook',
|
||||||
|
inapp: 'settings.notificationPreferences.inapp',
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||||
|
trip_invite: 'settings.notifyTripInvite',
|
||||||
|
booking_change: 'settings.notifyBookingChange',
|
||||||
|
trip_reminder: 'settings.notifyTripReminder',
|
||||||
|
vacay_invite: 'settings.notifyVacayInvite',
|
||||||
|
photos_shared: 'settings.notifyPhotosShared',
|
||||||
|
collab_message: 'settings.notifyCollabMessage',
|
||||||
|
packing_tagged: 'settings.notifyPackingTagged',
|
||||||
|
version_available: 'settings.notifyVersionAvailable',
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) {
|
function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) {
|
||||||
const [notifChannel, setNotifChannel] = useState<string>('none')
|
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [webhookUrl, setWebhookUrl] = useState('')
|
||||||
|
const [webhookSaving, setWebhookSaving] = useState(false)
|
||||||
|
const [webhookTesting, setWebhookTesting] = useState(false)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authApi.getAppConfig?.().then((cfg: any) => {
|
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
||||||
if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel)
|
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
|
||||||
|
setWebhookUrl((data.settings?.webhook_url as string) || '')
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (notifChannel === 'none') {
|
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading…</p>
|
||||||
|
|
||||||
|
// Which channels are both available AND have at least one implemented event
|
||||||
|
const visibleChannels = (['email', 'webhook', 'inapp'] as const).filter(ch => {
|
||||||
|
if (!matrix.available_channels[ch]) return false
|
||||||
|
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (visibleChannels.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||||
{t('settings.notificationsDisabled')}
|
{t('settings.notificationPreferences.noChannels')}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelLabel = notifChannel === 'email'
|
const toggle = async (eventType: string, channel: string) => {
|
||||||
? (t('admin.notifications.email') || 'Email (SMTP)')
|
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||||
: (t('admin.notifications.webhook') || 'Webhook')
|
const updated = {
|
||||||
|
...matrix.preferences,
|
||||||
|
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
|
||||||
|
}
|
||||||
|
setMatrix(m => m ? { ...m, preferences: updated } : m)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await notificationsApi.updatePreferences(updated)
|
||||||
|
} catch {
|
||||||
|
// Revert on failure
|
||||||
|
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveWebhookUrl = async () => {
|
||||||
|
setWebhookSaving(true)
|
||||||
|
try {
|
||||||
|
await settingsApi.set('webhook_url', webhookUrl)
|
||||||
|
toast.success(t('settings.webhookUrl.saved'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setWebhookSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testWebhookUrl = async () => {
|
||||||
|
if (!webhookUrl) return
|
||||||
|
setWebhookTesting(true)
|
||||||
|
try {
|
||||||
|
const result = await notificationsApi.testWebhook(webhookUrl)
|
||||||
|
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
|
||||||
|
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.webhookUrl.testFailed'))
|
||||||
|
} finally {
|
||||||
|
setWebhookTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#22c55e', flexShrink: 0 }} />
|
{/* Webhook URL configuration */}
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>
|
{matrix.available_channels.webhook && (
|
||||||
{t('settings.notificationsActive')}: {channelLabel}
|
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||||
</span>
|
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||||
|
{t('settings.webhookUrl.label')}
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={webhookUrl}
|
||||||
|
onChange={e => setWebhookUrl(e.target.value)}
|
||||||
|
placeholder={t('settings.webhookUrl.placeholder')}
|
||||||
|
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={saveWebhookUrl}
|
||||||
|
disabled={webhookSaving}
|
||||||
|
style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{t('settings.webhookUrl.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={testWebhookUrl}
|
||||||
|
disabled={!webhookUrl || webhookTesting}
|
||||||
|
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!webhookUrl || webhookTesting) ? 'not-allowed' : 'pointer', opacity: (!webhookUrl || webhookTesting) ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
{t('settings.webhookUrl.test')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Header row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||||
|
<span />
|
||||||
|
{visibleChannels.map(ch => (
|
||||||
|
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
{t(CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
|
{/* Event rows */}
|
||||||
{t('settings.notificationsManagedByAdmin')}
|
{matrix.event_types.map(eventType => {
|
||||||
</p>
|
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||||
|
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch))
|
||||||
|
if (relevantChannels.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
|
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||||
|
</span>
|
||||||
|
{visibleChannels.map(ch => {
|
||||||
|
if (!implementedForEvent.includes(ch)) {
|
||||||
|
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||||
|
}
|
||||||
|
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||||
|
return (
|
||||||
|
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,6 +550,58 @@ function runMigrations(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
|
// Migration 69: Normalized per-user per-channel notification preferences
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (user_id, event_type, channel)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migrate data from old notification_preferences table
|
||||||
|
const oldPrefs = db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>;
|
||||||
|
const eventCols: Record<string, string> = {
|
||||||
|
trip_invite: 'notify_trip_invite',
|
||||||
|
booking_change: 'notify_booking_change',
|
||||||
|
trip_reminder: 'notify_trip_reminder',
|
||||||
|
vacay_invite: 'notify_vacay_invite',
|
||||||
|
photos_shared: 'notify_photos_shared',
|
||||||
|
collab_message: 'notify_collab_message',
|
||||||
|
packing_tagged: 'notify_packing_tagged',
|
||||||
|
};
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
|
||||||
|
for (const [userId, eventType, channel, enabled] of rows) {
|
||||||
|
insert.run(userId, eventType, channel, enabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of oldPrefs) {
|
||||||
|
const userId = row.user_id as number;
|
||||||
|
const webhookEnabled = (row.notify_webhook as number) ?? 0;
|
||||||
|
const rows: Array<[number, string, string, number]> = [];
|
||||||
|
for (const [eventType, col] of Object.entries(eventCols)) {
|
||||||
|
const emailEnabled = (row[col] as number) ?? 1;
|
||||||
|
// Only insert if disabled (no row = enabled is our default)
|
||||||
|
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
|
||||||
|
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
|
||||||
|
}
|
||||||
|
if (rows.length > 0) insertMany(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy existing single-channel setting to new plural key
|
||||||
|
db.exec(`
|
||||||
|
INSERT OR IGNORE INTO app_settings (key, value)
|
||||||
|
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
|
||||||
|
`);
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -418,6 +418,15 @@ function createTables(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (user_id, event_type, channel)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const server = app.listen(PORT, () => {
|
|||||||
}
|
}
|
||||||
scheduler.start();
|
scheduler.start();
|
||||||
scheduler.startTripReminders();
|
scheduler.startTripReminders();
|
||||||
|
scheduler.startVersionCheck();
|
||||||
scheduler.startDemoReset();
|
scheduler.startDemoReset();
|
||||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||||
startTokenCleanup();
|
startTokenCleanup();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth';
|
|||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||||
import * as svc from '../services/adminService';
|
import * as svc from '../services/adminService';
|
||||||
|
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -132,6 +133,19 @@ router.get('/version-check', async (_req: Request, res: Response) => {
|
|||||||
res.json(await svc.checkVersion());
|
res.json(await svc.checkVersion());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Admin notification preferences ────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/notification-preferences', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/notification-preferences', (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
setAdminPreferences(authReq.user.id, req.body);
|
||||||
|
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
|
||||||
|
});
|
||||||
|
|
||||||
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/invites', (_req: Request, res: Response) => {
|
router.get('/invites', (_req: Request, res: Response) => {
|
||||||
@@ -313,38 +327,22 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
const { createNotification } = require('../services/inAppNotifications');
|
const { send } = require('../services/notificationService');
|
||||||
|
|
||||||
router.post('/dev/test-notification', (req: Request, res: Response) => {
|
router.post('/dev/test-notification', async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { type, scope, target, title_key, text_key, title_params, text_params,
|
const { event = 'trip_reminder', scope = 'user', targetId, params = {}, inApp } = req.body;
|
||||||
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 {
|
try {
|
||||||
const ids = createNotification(input);
|
await send({
|
||||||
res.json({ success: true, notification_ids: ids });
|
event,
|
||||||
|
actorId: authReq.user.id,
|
||||||
|
scope,
|
||||||
|
targetId: targetId ?? authReq.user.id,
|
||||||
|
params: { actor: authReq.user.email, ...params },
|
||||||
|
inApp,
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
|
|||||||
res.status(201).json({ note: formatted });
|
res.status(201).json({ note: formatted });
|
||||||
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email }).catch(() => {});
|
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, tripId: String(tripId) } }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,10 +256,10 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
|
|||||||
broadcast(tripId, 'collab:message:created', { message: result.message }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'collab:message:created', { message: result.message }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
// Notify trip members about new chat message
|
// Notify trip members about new chat message
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview }).catch(() => {});
|
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview, tripId: String(tripId) } }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -114,9 +114,9 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
|
|||||||
|
|
||||||
// Notify trip members about shared photos
|
// Notify trip members about shared photos
|
||||||
if (shared && added > 0) {
|
if (shared && added > 0) {
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {});
|
send({ event: 'photos_shared', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added), tripId: String(tripId) } }).catch(() => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,22 +12,19 @@ import {
|
|||||||
deleteAll,
|
deleteAll,
|
||||||
respondToBoolean,
|
respondToBoolean,
|
||||||
} from '../services/inAppNotifications';
|
} from '../services/inAppNotifications';
|
||||||
import * as prefsService from '../services/notificationPreferencesService';
|
import { getPreferencesMatrix, setPreferences } from '../services/notificationPreferencesService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
res.json({ preferences: prefsService.getPreferences(authReq.user.id) });
|
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
|
setPreferences(authReq.user.id, req.body);
|
||||||
const preferences = prefsService.updatePreferences(authReq.user.id, {
|
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
|
||||||
notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook
|
|
||||||
});
|
|
||||||
res.json({ preferences });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||||
@@ -38,9 +35,10 @@ router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
|
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const { url } = req.body;
|
||||||
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'url is required' });
|
||||||
res.json(await testWebhook());
|
try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
||||||
|
res.json(await testWebhook(url));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── In-app notifications ──────────────────────────────────────────────────────
|
// ── In-app notifications ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -224,13 +224,10 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
|||||||
|
|
||||||
// Notify newly assigned users
|
// Notify newly assigned users
|
||||||
if (Array.isArray(user_ids) && user_ids.length > 0) {
|
if (Array.isArray(user_ids) && user_ids.length > 0) {
|
||||||
import('../services/notifications').then(({ notify }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
for (const uid of user_ids) {
|
// Use trip scope so the service resolves recipients — actor is excluded automatically
|
||||||
if (uid !== authReq.user.id) {
|
send({ event: 'packing_tagged', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat, tripId: String(tripId) } }).catch(() => {});
|
||||||
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat } }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
// Notify trip members about new booking
|
// Notify trip members about new booking
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking' }).catch(() => {});
|
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,9 +107,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
res.json({ reservation });
|
res.json({ reservation });
|
||||||
broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking' }).catch(() => {});
|
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,9 +133,9 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
|
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -412,8 +412,8 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
|||||||
const result = addMember(req.params.id, identifier, tripOwnerId, authReq.user.id);
|
const result = addMember(req.params.id, identifier, tripOwnerId, authReq.user.id);
|
||||||
|
|
||||||
// Notify invited user
|
// Notify invited user
|
||||||
import('../services/notifications').then(({ notify }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
notify({ userId: result.targetUserId, event: 'trip_invite', params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email } }).catch(() => {});
|
send({ event: 'trip_invite', actorId: authReq.user.id, scope: 'user', targetId: result.targetUserId, params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email, tripId: String(req.params.id) } }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({ member: result.member });
|
res.status(201).json({ member: result.member });
|
||||||
|
|||||||
@@ -163,22 +163,23 @@ function startTripReminders(): void {
|
|||||||
try {
|
try {
|
||||||
const { db } = require('./db/database');
|
const { db } = require('./db/database');
|
||||||
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||||
const channel = getSetting('notification_channel') || 'none';
|
|
||||||
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
||||||
const hasSmtp = !!(getSetting('smtp_host') || '').trim();
|
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
||||||
const hasWebhook = !!(getSetting('notification_webhook_url') || '').trim();
|
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
||||||
const channelReady = (channel === 'email' && hasSmtp) || (channel === 'webhook' && hasWebhook);
|
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
|
||||||
|
const hasWebhook = activeChannels.includes('webhook');
|
||||||
|
const channelReady = hasEmail || hasWebhook;
|
||||||
|
|
||||||
if (!channelReady || !reminderEnabled) {
|
if (!channelReady || !reminderEnabled) {
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
const { logInfo: li } = require('./services/auditLog');
|
||||||
const reason = !channelReady ? `no ${channel === 'none' ? 'notification channel' : channel} configuration` : 'trip reminders disabled in settings';
|
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
|
||||||
li(`Trip reminders: disabled (${reason})`);
|
li(`Trip reminders: disabled (${reason})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
|
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
|
||||||
const { logInfo: liSetup } = require('./services/auditLog');
|
const { logInfo: liSetup } = require('./services/auditLog');
|
||||||
liSetup(`Trip reminders: enabled via ${channel}${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
liSetup(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ function startTripReminders(): void {
|
|||||||
reminderTask = cron.schedule('0 9 * * *', async () => {
|
reminderTask = cron.schedule('0 9 * * *', async () => {
|
||||||
try {
|
try {
|
||||||
const { db } = require('./db/database');
|
const { db } = require('./db/database');
|
||||||
const { notifyTripMembers } = require('./services/notifications');
|
const { send } = require('./services/notificationService');
|
||||||
|
|
||||||
const trips = db.prepare(`
|
const trips = db.prepare(`
|
||||||
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
|
SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
|
||||||
@@ -197,7 +198,7 @@ function startTripReminders(): void {
|
|||||||
`).all() as { id: number; title: string; user_id: number; reminder_days: number }[];
|
`).all() as { id: number; title: string; user_id: number; reminder_days: number }[];
|
||||||
|
|
||||||
for (const trip of trips) {
|
for (const trip of trips) {
|
||||||
await notifyTripMembers(trip.id, 0, 'trip_reminder', { trip: trip.title }).catch(() => {});
|
await send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
const { logInfo: li } = require('./services/auditLog');
|
||||||
@@ -211,10 +212,29 @@ function startTripReminders(): void {
|
|||||||
}, { timezone: tz });
|
}, { timezone: tz });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version check: daily at 9 AM — notify admins if a new TREK release is available
|
||||||
|
let versionCheckTask: ScheduledTask | null = null;
|
||||||
|
|
||||||
|
function startVersionCheck(): void {
|
||||||
|
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||||
|
|
||||||
|
const tz = process.env.TZ || 'UTC';
|
||||||
|
versionCheckTask = cron.schedule('0 9 * * *', async () => {
|
||||||
|
try {
|
||||||
|
const { checkAndNotifyVersion } = require('./services/adminService');
|
||||||
|
await checkAndNotifyVersion();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const { logError: le } = require('./services/auditLog');
|
||||||
|
le(`Version check: ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}, { timezone: tz });
|
||||||
|
}
|
||||||
|
|
||||||
function stop(): void {
|
function stop(): void {
|
||||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||||
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
||||||
|
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export { start, stop, startDemoReset, startTripReminders, loadSettings, saveSettings, VALID_INTERVALS };
|
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, loadSettings, saveSettings, VALID_INTERVALS };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
|||||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
||||||
import { revokeUserSessions } from '../mcp';
|
import { revokeUserSessions } from '../mcp';
|
||||||
import { validatePassword } from './passwordPolicy';
|
import { validatePassword } from './passwordPolicy';
|
||||||
|
import { send as sendNotification } from './notificationService';
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -312,6 +313,28 @@ export async function checkVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkAndNotifyVersion(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await checkVersion();
|
||||||
|
if (!result.update_available) return;
|
||||||
|
|
||||||
|
const lastNotified = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||||
|
if (lastNotified === result.latest) return;
|
||||||
|
|
||||||
|
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', result.latest);
|
||||||
|
|
||||||
|
await sendNotification({
|
||||||
|
event: 'version_available',
|
||||||
|
actorId: null,
|
||||||
|
scope: 'admin',
|
||||||
|
targetId: 0,
|
||||||
|
params: { version: result.latest as string },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — version check is non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function listInvites() {
|
export function listInvites() {
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ const MFA_BACKUP_CODE_COUNT = 10;
|
|||||||
const ADMIN_SETTINGS_KEYS = [
|
const ADMIN_SETTINGS_KEYS = [
|
||||||
'allow_registration', 'allowed_file_types', 'require_mfa',
|
'allow_registration', 'allowed_file_types', 'require_mfa',
|
||||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
||||||
'notification_webhook_url', 'notification_channel',
|
'notification_channels', 'admin_webhook_url',
|
||||||
'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder',
|
|
||||||
'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||||
@@ -195,8 +193,10 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
|||||||
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
|
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
|
||||||
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
|
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
|
||||||
const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value);
|
const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value);
|
||||||
const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value);
|
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
|
||||||
const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl);
|
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||||
|
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||||
|
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
|
||||||
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
|
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
|
||||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||||
|
|
||||||
@@ -216,6 +216,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
|||||||
demo_password: isDemo ? 'demo12345' : undefined,
|
demo_password: isDemo ? 'demo12345' : undefined,
|
||||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||||
notification_channel: notifChannel,
|
notification_channel: notifChannel,
|
||||||
|
notification_channels: activeChannels,
|
||||||
|
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||||
trip_reminders_enabled: tripRemindersEnabled,
|
trip_reminders_enabled: tripRemindersEnabled,
|
||||||
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
||||||
dev_mode: process.env.NODE_ENV === 'development',
|
dev_mode: process.env.NODE_ENV === 'development',
|
||||||
@@ -722,11 +724,9 @@ export function updateAppSettings(
|
|||||||
|
|
||||||
const summary: Record<string, unknown> = {};
|
const summary: Record<string, unknown> = {};
|
||||||
const smtpChanged = changedKeys.some(k => k.startsWith('smtp_'));
|
const smtpChanged = changedKeys.some(k => k.startsWith('smtp_'));
|
||||||
const eventsChanged = changedKeys.some(k => k.startsWith('notify_'));
|
if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels;
|
||||||
if (changedKeys.includes('notification_channel')) summary.notification_channel = body.notification_channel;
|
if (changedKeys.includes('admin_webhook_url')) summary.admin_webhook_url_updated = true;
|
||||||
if (changedKeys.includes('notification_webhook_url')) summary.webhook_url_updated = true;
|
|
||||||
if (smtpChanged) summary.smtp_settings_updated = true;
|
if (smtpChanged) summary.smtp_settings_updated = true;
|
||||||
if (eventsChanged) summary.notification_events_updated = true;
|
|
||||||
if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration;
|
if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration;
|
||||||
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
|
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
|
||||||
if (changedKeys.includes('require_mfa')) summary.require_mfa = body.require_mfa;
|
if (changedKeys.includes('require_mfa')) summary.require_mfa = body.require_mfa;
|
||||||
@@ -736,7 +736,7 @@ export function updateAppSettings(
|
|||||||
debugDetails[k] = k === 'smtp_pass' ? '***' : body[k];
|
debugDetails[k] = k === 'smtp_pass' ? '***' : body[k];
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder'];
|
const notifRelated = ['notification_channels', 'smtp_host'];
|
||||||
const shouldRestartScheduler = changedKeys.some(k => notifRelated.includes(k));
|
const shouldRestartScheduler = changedKeys.some(k => notifRelated.includes(k));
|
||||||
if (shouldRestartScheduler) {
|
if (shouldRestartScheduler) {
|
||||||
startTripReminders();
|
startTripReminders();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { broadcastToUser } from '../websocket';
|
import { broadcastToUser } from '../websocket';
|
||||||
import { getAction } from './inAppNotificationActions';
|
import { getAction } from './inAppNotificationActions';
|
||||||
|
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||||
|
|
||||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||||
@@ -11,6 +12,7 @@ interface BaseNotificationInput {
|
|||||||
scope: NotificationScope;
|
scope: NotificationScope;
|
||||||
target: number;
|
target: number;
|
||||||
sender_id: number | null;
|
sender_id: number | null;
|
||||||
|
event_type?: NotifEventType;
|
||||||
title_key: string;
|
title_key: string;
|
||||||
title_params?: Record<string, string>;
|
title_params?: Record<string, string>;
|
||||||
text_key: string;
|
text_key: string;
|
||||||
@@ -61,7 +63,7 @@ interface NotificationRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
export function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] {
|
||||||
let userIds: number[] = [];
|
let userIds: number[] = [];
|
||||||
|
|
||||||
if (scope === 'trip') {
|
if (scope === 'trip') {
|
||||||
@@ -93,7 +95,8 @@ function createNotification(input: NotificationInput): number[] {
|
|||||||
const titleParams = JSON.stringify(input.title_params ?? {});
|
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||||
const textParams = JSON.stringify(input.text_params ?? {});
|
const textParams = JSON.stringify(input.text_params ?? {});
|
||||||
|
|
||||||
const insertedIds: number[] = [];
|
// Track inserted id → recipientId pairs (some recipients may be skipped by pref check)
|
||||||
|
const insertedPairs: Array<{ id: number; recipientId: number }> = [];
|
||||||
|
|
||||||
const insert = db.transaction(() => {
|
const insert = db.transaction(() => {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
@@ -106,6 +109,11 @@ function createNotification(input: NotificationInput): number[] {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
for (const recipientId of recipients) {
|
for (const recipientId of recipients) {
|
||||||
|
// Check per-user in-app preference if an event_type is provided
|
||||||
|
if (input.event_type && !isEnabledForEvent(recipientId, input.event_type, 'inapp')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let positiveTextKey: string | null = null;
|
let positiveTextKey: string | null = null;
|
||||||
let negativeTextKey: string | null = null;
|
let negativeTextKey: string | null = null;
|
||||||
let positiveCallback: string | null = null;
|
let positiveCallback: string | null = null;
|
||||||
@@ -130,7 +138,7 @@ function createNotification(input: NotificationInput): number[] {
|
|||||||
navigateTextKey, navigateTarget
|
navigateTextKey, navigateTarget
|
||||||
);
|
);
|
||||||
|
|
||||||
insertedIds.push(result.lastInsertRowid as number);
|
insertedPairs.push({ id: result.lastInsertRowid as number, recipientId });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,9 +150,7 @@ function createNotification(input: NotificationInput): number[] {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Broadcast to each recipient
|
// Broadcast to each recipient
|
||||||
for (let i = 0; i < insertedIds.length; i++) {
|
for (const { id: notificationId, recipientId } of insertedPairs) {
|
||||||
const notificationId = insertedIds[i];
|
|
||||||
const recipientId = recipients[i];
|
|
||||||
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow;
|
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow;
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
|
|
||||||
@@ -158,7 +164,66 @@ function createNotification(input: NotificationInput): number[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return insertedIds;
|
return insertedPairs.map(p => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a single in-app notification for one pre-resolved recipient and broadcast via WebSocket.
|
||||||
|
* Used by notificationService.send() which handles recipient resolution externally.
|
||||||
|
*/
|
||||||
|
export function createNotificationForRecipient(
|
||||||
|
input: NotificationInput,
|
||||||
|
recipientId: number,
|
||||||
|
sender: { username: string; avatar: string | null } | null
|
||||||
|
): number | null {
|
||||||
|
const titleParams = JSON.stringify(input.title_params ?? {});
|
||||||
|
const textParams = JSON.stringify(input.text_params ?? {});
|
||||||
|
|
||||||
|
let positiveTextKey: string | null = null;
|
||||||
|
let negativeTextKey: string | null = null;
|
||||||
|
let positiveCallback: string | null = null;
|
||||||
|
let negativeCallback: string | null = null;
|
||||||
|
let navigateTextKey: string | null = null;
|
||||||
|
let navigateTarget: string | null = null;
|
||||||
|
|
||||||
|
if (input.type === 'boolean') {
|
||||||
|
positiveTextKey = input.positive_text_key;
|
||||||
|
negativeTextKey = input.negative_text_key;
|
||||||
|
positiveCallback = JSON.stringify(input.positive_callback);
|
||||||
|
negativeCallback = JSON.stringify(input.negative_callback);
|
||||||
|
} else if (input.type === 'navigate') {
|
||||||
|
navigateTextKey = input.navigate_text_key;
|
||||||
|
navigateTarget = input.navigate_target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO notifications (
|
||||||
|
type, scope, target, sender_id, recipient_id,
|
||||||
|
title_key, title_params, text_key, text_params,
|
||||||
|
positive_text_key, negative_text_key, positive_callback, negative_callback,
|
||||||
|
navigate_text_key, navigate_target
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
input.type, input.scope, input.target, input.sender_id, recipientId,
|
||||||
|
input.title_key, titleParams, input.text_key, textParams,
|
||||||
|
positiveTextKey, negativeTextKey, positiveCallback, negativeCallback,
|
||||||
|
navigateTextKey, navigateTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationId = result.lastInsertRowid as number;
|
||||||
|
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(notificationId) as NotificationRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
broadcastToUser(recipientId, {
|
||||||
|
type: 'notification:new',
|
||||||
|
notification: {
|
||||||
|
...row,
|
||||||
|
sender_username: sender?.username ?? null,
|
||||||
|
sender_avatar: sender?.avatar ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return notificationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNotifications(
|
function getNotifications(
|
||||||
@@ -266,55 +331,6 @@ async function respondToBoolean(
|
|||||||
return { success: true, 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 {
|
export {
|
||||||
createNotification,
|
createNotification,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
@@ -325,8 +341,6 @@ export {
|
|||||||
deleteNotification,
|
deleteNotification,
|
||||||
deleteAll,
|
deleteAll,
|
||||||
respondToBoolean,
|
respondToBoolean,
|
||||||
getPreferences,
|
|
||||||
updatePreferences,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };
|
export type { NotificationInput, NotificationRow, NotificationType, NotificationScope, NotificationResponse };
|
||||||
|
|||||||
@@ -1,40 +1,250 @@
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
|
import { decrypt_api_key } from './apiKeyCrypto';
|
||||||
|
|
||||||
export function getPreferences(userId: number) {
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
|
|
||||||
if (!prefs) {
|
export type NotifChannel = 'email' | 'webhook' | 'inapp';
|
||||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
|
|
||||||
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
|
export type NotifEventType =
|
||||||
}
|
| 'trip_invite'
|
||||||
return prefs;
|
| 'booking_change'
|
||||||
|
| 'trip_reminder'
|
||||||
|
| 'vacay_invite'
|
||||||
|
| 'photos_shared'
|
||||||
|
| 'collab_message'
|
||||||
|
| 'packing_tagged'
|
||||||
|
| 'version_available';
|
||||||
|
|
||||||
|
export interface AvailableChannels {
|
||||||
|
email: boolean;
|
||||||
|
webhook: boolean;
|
||||||
|
inapp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePreferences(
|
// Which channels are implemented for each event type.
|
||||||
userId: number,
|
// Only implemented combos show toggles in the user preferences UI.
|
||||||
fields: {
|
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
||||||
notify_trip_invite?: boolean;
|
trip_invite: ['inapp', 'email', 'webhook'],
|
||||||
notify_booking_change?: boolean;
|
booking_change: ['inapp', 'email', 'webhook'],
|
||||||
notify_trip_reminder?: boolean;
|
trip_reminder: ['inapp', 'email', 'webhook'],
|
||||||
notify_webhook?: boolean;
|
vacay_invite: ['inapp', 'email', 'webhook'],
|
||||||
}
|
photos_shared: ['inapp', 'email', 'webhook'],
|
||||||
) {
|
collab_message: ['inapp', 'email', 'webhook'],
|
||||||
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(userId);
|
packing_tagged: ['inapp', 'email', 'webhook'],
|
||||||
if (!existing) {
|
version_available: ['inapp', 'email', 'webhook'],
|
||||||
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId);
|
};
|
||||||
|
|
||||||
|
/** Events that target admins only (shown in admin panel, not in user settings). */
|
||||||
|
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available']);
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getAppSetting(key: string): string | null {
|
||||||
|
return (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active channels (admin-configured) ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns which channels the admin has enabled (email and/or webhook).
|
||||||
|
* Reads `notification_channels` (plural) with fallback to `notification_channel` (singular).
|
||||||
|
* In-app is always considered active at the service level.
|
||||||
|
*/
|
||||||
|
export function getActiveChannels(): NotifChannel[] {
|
||||||
|
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
|
||||||
|
if (raw === 'none') return [];
|
||||||
|
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns which channels are configured (have valid credentials/URLs set).
|
||||||
|
* In-app is always available. Email/webhook depend on configuration.
|
||||||
|
*/
|
||||||
|
export function getAvailableChannels(): AvailableChannels {
|
||||||
|
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||||
|
const hasWebhook = getActiveChannels().includes('webhook');
|
||||||
|
return { email: hasSmtp, webhook: hasWebhook, inapp: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the user has this event+channel enabled.
|
||||||
|
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
|
||||||
|
*/
|
||||||
|
export function isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean {
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(userId, eventType, channel) as { enabled: number } | undefined;
|
||||||
|
return row === undefined || row.enabled === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preferences matrix ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PreferencesMatrix {
|
||||||
|
preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
|
||||||
|
available_channels: AvailableChannels;
|
||||||
|
event_types: NotifEventType[];
|
||||||
|
implemented_combos: Record<NotifEventType, NotifChannel[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the preferences matrix for a user.
|
||||||
|
* scope='user' — excludes admin-scoped events (for user settings page)
|
||||||
|
* scope='admin' — returns only admin-scoped events (for admin notifications tab)
|
||||||
|
*/
|
||||||
|
export function getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix {
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?'
|
||||||
|
).all(userId) as Array<{ event_type: string; channel: string; enabled: number }>;
|
||||||
|
|
||||||
|
// Build a lookup from stored rows
|
||||||
|
const stored: Partial<Record<string, Partial<Record<string, boolean>>>> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!stored[row.event_type]) stored[row.event_type] = {};
|
||||||
|
stored[row.event_type]![row.channel] = row.enabled === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`UPDATE notification_preferences SET
|
// Build the full matrix with defaults (true when no row exists)
|
||||||
notify_trip_invite = COALESCE(?, notify_trip_invite),
|
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
|
||||||
notify_booking_change = COALESCE(?, notify_booking_change),
|
const allEvents = Object.keys(IMPLEMENTED_COMBOS) as NotifEventType[];
|
||||||
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
|
|
||||||
notify_webhook = COALESCE(?, notify_webhook)
|
for (const eventType of allEvents) {
|
||||||
WHERE user_id = ?`).run(
|
const channels = IMPLEMENTED_COMBOS[eventType];
|
||||||
fields.notify_trip_invite !== undefined ? (fields.notify_trip_invite ? 1 : 0) : null,
|
preferences[eventType] = {};
|
||||||
fields.notify_booking_change !== undefined ? (fields.notify_booking_change ? 1 : 0) : null,
|
for (const channel of channels) {
|
||||||
fields.notify_trip_reminder !== undefined ? (fields.notify_trip_reminder ? 1 : 0) : null,
|
// Admin-scoped events use global settings for email/webhook
|
||||||
fields.notify_webhook !== undefined ? (fields.notify_webhook ? 1 : 0) : null,
|
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook')) {
|
||||||
userId
|
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
|
||||||
|
} else {
|
||||||
|
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter event types by scope
|
||||||
|
const event_types = scope === 'admin'
|
||||||
|
? allEvents.filter(e => ADMIN_SCOPED_EVENTS.has(e))
|
||||||
|
: allEvents.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
|
||||||
|
|
||||||
|
// Available channels depend on scope
|
||||||
|
let available_channels: AvailableChannels;
|
||||||
|
if (scope === 'admin') {
|
||||||
|
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||||
|
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
|
||||||
|
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, inapp: true };
|
||||||
|
} else {
|
||||||
|
const activeChannels = getActiveChannels();
|
||||||
|
available_channels = {
|
||||||
|
email: activeChannels.includes('email'),
|
||||||
|
webhook: activeChannels.includes('webhook'),
|
||||||
|
inapp: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferences,
|
||||||
|
available_channels,
|
||||||
|
event_types,
|
||||||
|
implemented_combos: IMPLEMENTED_COMBOS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin global preferences (stored in app_settings) ─────────────────────
|
||||||
|
|
||||||
|
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the global admin preference for an event+channel.
|
||||||
|
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
||||||
|
* Defaults to true (enabled) when no row exists.
|
||||||
|
*/
|
||||||
|
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook'): boolean {
|
||||||
|
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||||
|
return val !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook', enabled: boolean): void {
|
||||||
|
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||||
|
`admin_notif_pref_${event}_${channel}`,
|
||||||
|
enabled ? '1' : '0'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preferences update ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-update preferences from the matrix UI.
|
||||||
|
* Inserts disabled rows (enabled=0) and removes rows that are enabled (default).
|
||||||
|
*/
|
||||||
|
export function setPreferences(
|
||||||
|
userId: number,
|
||||||
|
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||||
|
): void {
|
||||||
|
const upsert = db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
const del = db.prepare(
|
||||||
|
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
);
|
);
|
||||||
|
|
||||||
return db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId);
|
db.transaction(() => {
|
||||||
|
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||||
|
if (!channels) continue;
|
||||||
|
for (const [channel, enabled] of Object.entries(channels)) {
|
||||||
|
if (enabled) {
|
||||||
|
// Remove explicit row — default is enabled
|
||||||
|
del.run(userId, eventType, channel);
|
||||||
|
} else {
|
||||||
|
upsert.run(userId, eventType, channel, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-update admin notification preferences.
|
||||||
|
* email/webhook channels are stored globally in app_settings (not per-user).
|
||||||
|
* inapp channel remains per-user in notification_channel_preferences.
|
||||||
|
*/
|
||||||
|
export function setAdminPreferences(
|
||||||
|
userId: number,
|
||||||
|
prefs: Partial<Record<string, Partial<Record<string, boolean>>>>
|
||||||
|
): void {
|
||||||
|
const upsert = db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
const del = db.prepare(
|
||||||
|
'DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const [eventType, channels] of Object.entries(prefs)) {
|
||||||
|
if (!channels) continue;
|
||||||
|
for (const [channel, enabled] of Object.entries(channels)) {
|
||||||
|
if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) {
|
||||||
|
// Global setting — stored in app_settings
|
||||||
|
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled);
|
||||||
|
} else {
|
||||||
|
// Per-user (inapp)
|
||||||
|
if (enabled) {
|
||||||
|
del.run(userId, eventType, channel);
|
||||||
|
} else {
|
||||||
|
upsert.run(userId, eventType, channel, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SMTP availability helper (for authService) ─────────────────────────────
|
||||||
|
|
||||||
|
export function isSmtpConfigured(): boolean {
|
||||||
|
return !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebhookConfigured(): boolean {
|
||||||
|
return getActiveChannels().includes('webhook');
|
||||||
}
|
}
|
||||||
|
|||||||
278
server/src/services/notificationService.ts
Normal file
278
server/src/services/notificationService.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { db } from '../db/database';
|
||||||
|
import { logDebug } from './auditLog';
|
||||||
|
import {
|
||||||
|
getActiveChannels,
|
||||||
|
isEnabledForEvent,
|
||||||
|
getAdminGlobalPref,
|
||||||
|
isSmtpConfigured,
|
||||||
|
ADMIN_SCOPED_EVENTS,
|
||||||
|
type NotifEventType,
|
||||||
|
} from './notificationPreferencesService';
|
||||||
|
import {
|
||||||
|
getEventText,
|
||||||
|
sendEmail,
|
||||||
|
sendWebhook,
|
||||||
|
getUserEmail,
|
||||||
|
getUserLanguage,
|
||||||
|
getUserWebhookUrl,
|
||||||
|
getAdminWebhookUrl,
|
||||||
|
getAppUrl,
|
||||||
|
} from './notifications';
|
||||||
|
import {
|
||||||
|
resolveRecipients,
|
||||||
|
createNotificationForRecipient,
|
||||||
|
type NotificationInput,
|
||||||
|
} from './inAppNotifications';
|
||||||
|
|
||||||
|
// ── Event config map ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EventNotifConfig {
|
||||||
|
inAppType: 'simple' | 'navigate';
|
||||||
|
titleKey: string;
|
||||||
|
textKey: string;
|
||||||
|
navigateTextKey?: string;
|
||||||
|
navigateTarget: (params: Record<string, string>) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||||
|
// ── Dev-only test events ──────────────────────────────────────────────────
|
||||||
|
test_simple: {
|
||||||
|
inAppType: 'simple',
|
||||||
|
titleKey: 'notif.test.title',
|
||||||
|
textKey: 'notif.test.simple.text',
|
||||||
|
navigateTarget: () => null,
|
||||||
|
},
|
||||||
|
test_boolean: {
|
||||||
|
inAppType: 'simple', // overridden by inApp.type at call site
|
||||||
|
titleKey: 'notif.test.title',
|
||||||
|
textKey: 'notif.test.boolean.text',
|
||||||
|
navigateTarget: () => null,
|
||||||
|
},
|
||||||
|
test_navigate: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.test.title',
|
||||||
|
textKey: 'notif.test.navigate.text',
|
||||||
|
navigateTextKey: 'notif.action.view',
|
||||||
|
navigateTarget: () => '/dashboard',
|
||||||
|
},
|
||||||
|
// ── Production events ─────────────────────────────────────────────────────
|
||||||
|
trip_invite: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.trip_invite.title',
|
||||||
|
textKey: 'notif.trip_invite.text',
|
||||||
|
navigateTextKey: 'notif.action.view_trip',
|
||||||
|
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||||
|
},
|
||||||
|
booking_change: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.booking_change.title',
|
||||||
|
textKey: 'notif.booking_change.text',
|
||||||
|
navigateTextKey: 'notif.action.view_trip',
|
||||||
|
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||||
|
},
|
||||||
|
trip_reminder: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.trip_reminder.title',
|
||||||
|
textKey: 'notif.trip_reminder.text',
|
||||||
|
navigateTextKey: 'notif.action.view_trip',
|
||||||
|
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||||
|
},
|
||||||
|
vacay_invite: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.vacay_invite.title',
|
||||||
|
textKey: 'notif.vacay_invite.text',
|
||||||
|
navigateTextKey: 'notif.action.view_vacay',
|
||||||
|
navigateTarget: p => (p.planId ? `/vacay/${p.planId}` : null),
|
||||||
|
},
|
||||||
|
photos_shared: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.photos_shared.title',
|
||||||
|
textKey: 'notif.photos_shared.text',
|
||||||
|
navigateTextKey: 'notif.action.view_trip',
|
||||||
|
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||||
|
},
|
||||||
|
collab_message: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.collab_message.title',
|
||||||
|
textKey: 'notif.collab_message.text',
|
||||||
|
navigateTextKey: 'notif.action.view_collab',
|
||||||
|
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||||
|
},
|
||||||
|
packing_tagged: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.packing_tagged.title',
|
||||||
|
textKey: 'notif.packing_tagged.text',
|
||||||
|
navigateTextKey: 'notif.action.view_packing',
|
||||||
|
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
|
||||||
|
},
|
||||||
|
version_available: {
|
||||||
|
inAppType: 'navigate',
|
||||||
|
titleKey: 'notif.version_available.title',
|
||||||
|
textKey: 'notif.version_available.text',
|
||||||
|
navigateTextKey: 'notif.action.view_admin',
|
||||||
|
navigateTarget: () => '/admin',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Fallback config for unknown event types ────────────────────────────────
|
||||||
|
|
||||||
|
const FALLBACK_EVENT_CONFIG: EventNotifConfig = {
|
||||||
|
inAppType: 'simple',
|
||||||
|
titleKey: 'notif.generic.title',
|
||||||
|
textKey: 'notif.generic.text',
|
||||||
|
navigateTarget: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Unified send() API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NotificationPayload {
|
||||||
|
event: NotifEventType;
|
||||||
|
actorId: number | null;
|
||||||
|
params: Record<string, string>;
|
||||||
|
scope: 'trip' | 'user' | 'admin';
|
||||||
|
targetId: number; // tripId for trip scope, userId for user scope, 0 for admin
|
||||||
|
/** Optional in-app overrides (e.g. boolean type with callbacks) */
|
||||||
|
inApp?: {
|
||||||
|
type?: 'simple' | 'boolean' | 'navigate';
|
||||||
|
positiveTextKey?: string;
|
||||||
|
negativeTextKey?: string;
|
||||||
|
positiveCallback?: { action: string; payload: Record<string, unknown> };
|
||||||
|
negativeCallback?: { action: string; payload: Record<string, unknown> };
|
||||||
|
navigateTarget?: string; // override the auto-generated navigate target
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function send(payload: NotificationPayload): Promise<void> {
|
||||||
|
const { event, actorId, params, scope, targetId, inApp } = payload;
|
||||||
|
|
||||||
|
// Resolve recipients based on scope
|
||||||
|
const recipients = resolveRecipients(scope, targetId, actorId);
|
||||||
|
if (recipients.length === 0) return;
|
||||||
|
|
||||||
|
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||||
|
if (!configEntry) {
|
||||||
|
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||||
|
if (process.env.NODE_ENV === 'development' && actorId != null) {
|
||||||
|
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
|
||||||
|
createNotificationForRecipient({
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'user',
|
||||||
|
target: actorId,
|
||||||
|
sender_id: null,
|
||||||
|
title_key: 'notif.dev.unknown_event.title',
|
||||||
|
text_key: 'notif.dev.unknown_event.text',
|
||||||
|
text_params: { event },
|
||||||
|
}, actorId, devSender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
|
||||||
|
const activeChannels = getActiveChannels();
|
||||||
|
const appUrl = getAppUrl();
|
||||||
|
|
||||||
|
// Build navigate target (used by email/webhook CTA and in-app navigate)
|
||||||
|
const navigateTarget = inApp?.navigateTarget ?? config.navigateTarget(params);
|
||||||
|
const fullLink = navigateTarget ? `${appUrl}${navigateTarget}` : undefined;
|
||||||
|
|
||||||
|
// Fetch sender info once for in-app WS payloads
|
||||||
|
const sender = actorId
|
||||||
|
? (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
logDebug(`notificationService.send event=${event} scope=${scope} targetId=${targetId} recipients=${recipients.length} channels=inapp,${activeChannels.join(',')}`);
|
||||||
|
|
||||||
|
// Dispatch to each recipient in parallel
|
||||||
|
await Promise.all(recipients.map(async (recipientId) => {
|
||||||
|
const promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
// ── In-app ──────────────────────────────────────────────────────────
|
||||||
|
if (isEnabledForEvent(recipientId, event, 'inapp')) {
|
||||||
|
const inAppType = inApp?.type ?? config.inAppType;
|
||||||
|
let notifInput: NotificationInput;
|
||||||
|
|
||||||
|
if (inAppType === 'boolean' && inApp?.positiveCallback && inApp?.negativeCallback) {
|
||||||
|
notifInput = {
|
||||||
|
type: 'boolean',
|
||||||
|
scope,
|
||||||
|
target: targetId,
|
||||||
|
sender_id: actorId,
|
||||||
|
event_type: event,
|
||||||
|
title_key: config.titleKey,
|
||||||
|
title_params: params,
|
||||||
|
text_key: config.textKey,
|
||||||
|
text_params: params,
|
||||||
|
positive_text_key: inApp.positiveTextKey ?? 'notif.action.accept',
|
||||||
|
negative_text_key: inApp.negativeTextKey ?? 'notif.action.decline',
|
||||||
|
positive_callback: inApp.positiveCallback,
|
||||||
|
negative_callback: inApp.negativeCallback,
|
||||||
|
};
|
||||||
|
} else if (inAppType === 'navigate' && navigateTarget) {
|
||||||
|
notifInput = {
|
||||||
|
type: 'navigate',
|
||||||
|
scope,
|
||||||
|
target: targetId,
|
||||||
|
sender_id: actorId,
|
||||||
|
event_type: event,
|
||||||
|
title_key: config.titleKey,
|
||||||
|
title_params: params,
|
||||||
|
text_key: config.textKey,
|
||||||
|
text_params: params,
|
||||||
|
navigate_text_key: config.navigateTextKey ?? 'notif.action.view',
|
||||||
|
navigate_target: navigateTarget,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
notifInput = {
|
||||||
|
type: 'simple',
|
||||||
|
scope,
|
||||||
|
target: targetId,
|
||||||
|
sender_id: actorId,
|
||||||
|
event_type: event,
|
||||||
|
title_key: config.titleKey,
|
||||||
|
title_params: params,
|
||||||
|
text_key: config.textKey,
|
||||||
|
text_params: params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
Promise.resolve().then(() => createNotificationForRecipient(notifInput, recipientId, sender ?? null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email ────────────────────────────────────────────────────────────
|
||||||
|
// Admin-scoped events: use global pref + SMTP check (bypass notification_channels toggle)
|
||||||
|
// Regular events: use active channels + per-user pref
|
||||||
|
const emailEnabled = ADMIN_SCOPED_EVENTS.has(event)
|
||||||
|
? isSmtpConfigured() && getAdminGlobalPref(event, 'email')
|
||||||
|
: activeChannels.includes('email') && isEnabledForEvent(recipientId, event, 'email');
|
||||||
|
|
||||||
|
if (emailEnabled) {
|
||||||
|
const email = getUserEmail(recipientId);
|
||||||
|
if (email) {
|
||||||
|
const lang = getUserLanguage(recipientId);
|
||||||
|
const { title, body } = getEventText(lang, event, params);
|
||||||
|
promises.push(sendEmail(email, title, body, recipientId, navigateTarget ?? undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Webhook (per-user) — skip for admin-scoped events (handled globally below) ──
|
||||||
|
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('webhook') && isEnabledForEvent(recipientId, event, 'webhook')) {
|
||||||
|
const webhookUrl = getUserWebhookUrl(recipientId);
|
||||||
|
if (webhookUrl) {
|
||||||
|
const lang = getUserLanguage(recipientId);
|
||||||
|
const { title, body } = getEventText(lang, event, params);
|
||||||
|
promises.push(sendWebhook(webhookUrl, { event, title, body, tripName: params.trip, link: fullLink }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Admin webhook (scope: admin) — global, respects global pref ──────
|
||||||
|
if (scope === 'admin' && getAdminGlobalPref(event, 'webhook')) {
|
||||||
|
const adminWebhookUrl = getAdminWebhookUrl();
|
||||||
|
if (adminWebhookUrl) {
|
||||||
|
const { title, body } = getEventText('en', event, params);
|
||||||
|
sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,7 @@ import { logInfo, logDebug, logError } from './auditLog';
|
|||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type EventType = 'trip_invite' | 'booking_change' | 'trip_reminder' | 'vacay_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged';
|
import type { NotifEventType } from './notificationPreferencesService';
|
||||||
|
|
||||||
interface NotificationPayload {
|
|
||||||
userId: number;
|
|
||||||
event: EventType;
|
|
||||||
params: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SmtpConfig {
|
interface SmtpConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -39,11 +33,8 @@ function getSmtpConfig(): SmtpConfig | null {
|
|||||||
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
|
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWebhookUrl(): string | null {
|
// Exported for use by notificationService
|
||||||
return process.env.NOTIFICATION_WEBHOOK_URL || getAppSetting('notification_webhook_url');
|
export function getAppUrl(): string {
|
||||||
}
|
|
||||||
|
|
||||||
function getAppUrl(): string {
|
|
||||||
if (process.env.APP_URL) return process.env.APP_URL;
|
if (process.env.APP_URL) return process.env.APP_URL;
|
||||||
const origins = process.env.ALLOWED_ORIGINS;
|
const origins = process.env.ALLOWED_ORIGINS;
|
||||||
if (origins) {
|
if (origins) {
|
||||||
@@ -54,31 +45,21 @@ function getAppUrl(): string {
|
|||||||
return `http://localhost:${port}`;
|
return `http://localhost:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserEmail(userId: number): string | null {
|
export function getUserEmail(userId: number): string | null {
|
||||||
return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null;
|
return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserLanguage(userId: number): string {
|
export function getUserLanguage(userId: number): string {
|
||||||
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
|
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAdminEventEnabled(event: EventType): boolean {
|
export function getUserWebhookUrl(userId: number): string | null {
|
||||||
const prefKey = EVENT_PREF_MAP[event];
|
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as { value: string } | undefined)?.value || null;
|
||||||
if (!prefKey) return true;
|
|
||||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(prefKey) as { value: string } | undefined;
|
|
||||||
return !row || row.value !== 'false';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event → preference column mapping
|
export function getAdminWebhookUrl(): string | null {
|
||||||
const EVENT_PREF_MAP: Record<EventType, string> = {
|
return getAppSetting('admin_webhook_url') || null;
|
||||||
trip_invite: 'notify_trip_invite',
|
}
|
||||||
booking_change: 'notify_booking_change',
|
|
||||||
trip_reminder: 'notify_trip_reminder',
|
|
||||||
vacay_invite: 'notify_vacay_invite',
|
|
||||||
photos_shared: 'notify_photos_shared',
|
|
||||||
collab_message: 'notify_collab_message',
|
|
||||||
packing_tagged: 'notify_packing_tagged',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Email i18n strings ─────────────────────────────────────────────────────
|
// ── Email i18n strings ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -99,7 +80,7 @@ const I18N: Record<string, EmailStrings> = {
|
|||||||
interface EventText { title: string; body: string }
|
interface EventText { title: string; body: string }
|
||||||
type EventTextFn = (params: Record<string, string>) => EventText
|
type EventTextFn = (params: Record<string, string>) => EventText
|
||||||
|
|
||||||
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
|
||||||
en: {
|
en: {
|
||||||
trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }),
|
trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }),
|
||||||
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
|
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
|
||||||
@@ -108,6 +89,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
|
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
|
||||||
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }),
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
|
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
|
||||||
@@ -117,6 +99,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
|
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
|
||||||
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
|
||||||
|
version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }),
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
|
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
|
||||||
@@ -126,6 +109,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
|
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
|
||||||
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
|
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
|
||||||
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }),
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
|
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
|
||||||
@@ -135,6 +119,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
|
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
|
||||||
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }),
|
||||||
},
|
},
|
||||||
nl: {
|
nl: {
|
||||||
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
|
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
|
||||||
@@ -144,6 +129,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
|
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
|
||||||
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }),
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
|
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
|
||||||
@@ -153,6 +139,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
|
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
|
||||||
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }),
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
|
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
|
||||||
@@ -162,6 +149,7 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
|
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
|
||||||
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
|
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
|
||||||
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
|
||||||
|
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }),
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
|
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
|
||||||
@@ -171,21 +159,74 @@ const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
|||||||
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
|
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
|
||||||
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
|
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }),
|
||||||
|
},
|
||||||
|
br: {
|
||||||
|
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
|
||||||
|
booking_change: p => ({ title: `Nova reserva: ${p.booking}`, body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".` }),
|
||||||
|
trip_reminder: p => ({ title: `Lembrete: ${p.trip}`, body: `Sua viagem "${p.trip}" está chegando!` }),
|
||||||
|
vacay_invite: p => ({ title: 'Convite Vacay Fusion', body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.` }),
|
||||||
|
photos_shared: p => ({ title: `${p.count} fotos compartilhadas`, body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".` }),
|
||||||
|
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
|
packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }),
|
||||||
|
},
|
||||||
|
cs: {
|
||||||
|
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
|
||||||
|
booking_change: p => ({ title: `Nová rezervace: ${p.booking}`, body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".` }),
|
||||||
|
trip_reminder: p => ({ title: `Připomínka výletu: ${p.trip}`, body: `Váš výlet "${p.trip}" se blíží!` }),
|
||||||
|
vacay_invite: p => ({ title: 'Pozvánka Vacay Fusion', body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.` }),
|
||||||
|
photos_shared: p => ({ title: `${p.count} sdílených fotek`, body: `${p.actor} sdílel ${p.count} foto v "${p.trip}".` }),
|
||||||
|
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
|
packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }),
|
||||||
|
},
|
||||||
|
hu: {
|
||||||
|
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
|
||||||
|
booking_change: p => ({ title: `Új foglalás: ${p.booking}`, body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.` }),
|
||||||
|
trip_reminder: p => ({ title: `Utazás emlékeztető: ${p.trip}`, body: `A(z) "${p.trip}" utazás hamarosan kezdődik!` }),
|
||||||
|
vacay_invite: p => ({ title: 'Vacay Fusion meghívó', body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.` }),
|
||||||
|
photos_shared: p => ({ title: `${p.count} fotó megosztva`, body: `${p.actor} ${p.count} fotót osztott meg a(z) "${p.trip}" utazásban.` }),
|
||||||
|
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
|
||||||
|
packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }),
|
||||||
|
version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }),
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
|
||||||
|
booking_change: p => ({ title: `Nuova prenotazione: ${p.booking}`, body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".` }),
|
||||||
|
trip_reminder: p => ({ title: `Promemoria viaggio: ${p.trip}`, body: `Il tuo viaggio "${p.trip}" si avvicina!` }),
|
||||||
|
vacay_invite: p => ({ title: 'Invito Vacay Fusion', body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.` }),
|
||||||
|
photos_shared: p => ({ title: `${p.count} foto condivise`, body: `${p.actor} ha condiviso ${p.count} foto in "${p.trip}".` }),
|
||||||
|
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
|
packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }),
|
||||||
|
},
|
||||||
|
pl: {
|
||||||
|
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
|
||||||
|
booking_change: p => ({ title: `Nowa rezerwacja: ${p.booking}`, body: `${p.actor} dodał rezerwację "${p.booking}" (${p.type}) do "${p.trip}".` }),
|
||||||
|
trip_reminder: p => ({ title: `Przypomnienie o podróży: ${p.trip}`, body: `Twoja podróż "${p.trip}" zbliża się!` }),
|
||||||
|
vacay_invite: p => ({ title: 'Zaproszenie Vacay Fusion', body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.` }),
|
||||||
|
photos_shared: p => ({ title: `${p.count} zdjęć udostępnionych`, body: `${p.actor} udostępnił ${p.count} zdjęcie/zdjęcia w "${p.trip}".` }),
|
||||||
|
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
|
||||||
|
packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }),
|
||||||
|
version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get localized event text
|
// Get localized event text
|
||||||
export function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
|
export function getEventText(lang: string, event: NotifEventType, params: Record<string, string>): EventText {
|
||||||
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
|
||||||
return texts[event](params);
|
const fn = texts[event] ?? EVENT_TEXTS.en[event];
|
||||||
|
if (!fn) return { title: event, body: '' };
|
||||||
|
return fn(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function buildEmailHtml(subject: string, body: string, lang: string): string {
|
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
|
||||||
const s = I18N[lang] || I18N.en;
|
const s = I18N[lang] || I18N.en;
|
||||||
const appUrl = getAppUrl();
|
const appUrl = getAppUrl();
|
||||||
const ctaHref = appUrl || '#';
|
const ctaHref = navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || '');
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -224,7 +265,7 @@ export function buildEmailHtml(subject: string, body: string, lang: string): str
|
|||||||
|
|
||||||
// ── Send functions ─────────────────────────────────────────────────────────
|
// ── Send functions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise<boolean> {
|
export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise<boolean> {
|
||||||
const config = getSmtpConfig();
|
const config = getSmtpConfig();
|
||||||
if (!config) return false;
|
if (!config) return false;
|
||||||
|
|
||||||
@@ -245,7 +286,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
|||||||
to,
|
to,
|
||||||
subject: `TREK — ${subject}`,
|
subject: `TREK — ${subject}`,
|
||||||
text: body,
|
text: body,
|
||||||
html: buildEmailHtml(subject, body, lang),
|
html: buildEmailHtml(subject, body, lang, navigateTarget),
|
||||||
});
|
});
|
||||||
logInfo(`Email sent to=${to} subject="${subject}"`);
|
logInfo(`Email sent to=${to} subject="${subject}"`);
|
||||||
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
|
logDebug(`Email smtp=${config.host}:${config.port} from=${config.from} to=${to}`);
|
||||||
@@ -256,7 +297,7 @@ async function sendEmail(to: string, subject: string, body: string, userId?: num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string }): string {
|
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): string {
|
||||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||||
|
|
||||||
@@ -265,6 +306,7 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
|
|||||||
embeds: [{
|
embeds: [{
|
||||||
title: `📍 ${payload.title}`,
|
title: `📍 ${payload.title}`,
|
||||||
description: payload.body,
|
description: payload.body,
|
||||||
|
url: payload.link,
|
||||||
color: 0x3b82f6,
|
color: 0x3b82f6,
|
||||||
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -274,16 +316,16 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
|
|||||||
|
|
||||||
if (isSlack) {
|
if (isSlack) {
|
||||||
const trip = payload.tripName ? ` • _${payload.tripName}_` : '';
|
const trip = payload.tripName ? ` • _${payload.tripName}_` : '';
|
||||||
|
const link = payload.link ? `\n<${payload.link}|Open in TREK>` : '';
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
text: `*${payload.title}*\n${payload.body}${trip}`,
|
text: `*${payload.title}*\n${payload.body}${trip}${link}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
|
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise<boolean> {
|
export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise<boolean> {
|
||||||
const url = getWebhookUrl();
|
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -309,56 +351,6 @@ async function sendWebhook(payload: { event: string; title: string; body: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getNotificationChannel(): string {
|
|
||||||
return getAppSetting('notification_channel') || 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function notify(payload: NotificationPayload): Promise<void> {
|
|
||||||
const channel = getNotificationChannel();
|
|
||||||
if (channel === 'none') return;
|
|
||||||
|
|
||||||
if (!getAdminEventEnabled(payload.event)) return;
|
|
||||||
|
|
||||||
const lang = getUserLanguage(payload.userId);
|
|
||||||
const { title, body } = getEventText(lang, payload.event, payload.params);
|
|
||||||
|
|
||||||
logDebug(`Notification event=${payload.event} channel=${channel} userId=${payload.userId} params=${JSON.stringify(payload.params)}`);
|
|
||||||
|
|
||||||
if (channel === 'email') {
|
|
||||||
const email = getUserEmail(payload.userId);
|
|
||||||
if (email) await sendEmail(email, title, body, payload.userId);
|
|
||||||
} else if (channel === 'webhook') {
|
|
||||||
await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record<string, string>): Promise<void> {
|
|
||||||
const channel = getNotificationChannel();
|
|
||||||
if (channel === 'none') return;
|
|
||||||
if (!getAdminEventEnabled(event)) return;
|
|
||||||
|
|
||||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
|
|
||||||
if (!trip) return;
|
|
||||||
|
|
||||||
if (channel === 'webhook') {
|
|
||||||
const lang = getUserLanguage(actorUserId);
|
|
||||||
const { title, body } = getEventText(lang, event, params);
|
|
||||||
logDebug(`notifyTripMembers event=${event} channel=webhook tripId=${tripId} actor=${actorUserId}`);
|
|
||||||
await sendWebhook({ event, title, body, tripName: params.trip });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
|
|
||||||
const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
|
|
||||||
const unique = [...new Set(allIds)];
|
|
||||||
|
|
||||||
for (const userId of unique) {
|
|
||||||
await notify({ userId, event, params });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
|
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
|
||||||
@@ -368,11 +360,12 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testWebhook(): Promise<{ success: boolean; error?: string }> {
|
export async function testWebhook(url: string): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const sent = await sendWebhook({ event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
|
const sent = await sendWebhook(url, { event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
|
||||||
return sent ? { success: true } : { success: false, error: 'Webhook URL not configured' };
|
return sent ? { success: true } : { success: false, error: 'Failed to send webhook' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -342,8 +342,8 @@ export function sendInvite(planId: number, inviterId: number, inviterUsername: s
|
|||||||
} catch { /* websocket not available */ }
|
} catch { /* websocket not available */ }
|
||||||
|
|
||||||
// Notify invited user
|
// Notify invited user
|
||||||
import('../services/notifications').then(({ notify }) => {
|
import('../services/notificationService').then(({ send }) => {
|
||||||
notify({ userId: targetUserId, event: 'vacay_invite', params: { actor: inviterEmail } }).catch(() => {});
|
send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -480,3 +480,29 @@ export function createInviteToken(
|
|||||||
).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy);
|
).run(token, overrides.max_uses ?? 1, overrides.expires_at ?? null, createdBy);
|
||||||
return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken;
|
return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(result.lastInsertRowid) as TestInviteToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Notification helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Upsert a key/value pair into app_settings. */
|
||||||
|
export function setAppSetting(db: Database.Database, key: string, value: string): void {
|
||||||
|
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the active notification channels (e.g. 'email', 'webhook', 'email,webhook', 'none'). */
|
||||||
|
export function setNotificationChannels(db: Database.Database, channels: string): void {
|
||||||
|
setAppSetting(db, 'notification_channels', channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicitly disable a per-user notification preference for a given event+channel combo. */
|
||||||
|
export function disableNotificationPref(
|
||||||
|
db: Database.Database,
|
||||||
|
userId: number,
|
||||||
|
eventType: string,
|
||||||
|
channel: string
|
||||||
|
): void {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)'
|
||||||
|
).run(userId, eventType, channel);
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const RESET_TABLES = [
|
|||||||
'vacay_plans',
|
'vacay_plans',
|
||||||
'atlas_visited_countries',
|
'atlas_visited_countries',
|
||||||
'atlas_bucket_list',
|
'atlas_bucket_list',
|
||||||
|
'notification_channel_preferences',
|
||||||
'notifications',
|
'notifications',
|
||||||
'audit_log',
|
'audit_log',
|
||||||
'user_settings',
|
'user_settings',
|
||||||
|
|||||||
@@ -39,12 +39,13 @@ vi.mock('../../src/config', () => ({
|
|||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
}));
|
}));
|
||||||
|
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||||
|
|
||||||
import { createApp } from '../../src/app';
|
import { createApp } from '../../src/app';
|
||||||
import { createTables } from '../../src/db/schema';
|
import { createTables } from '../../src/db/schema';
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
import { runMigrations } from '../../src/db/migrations';
|
||||||
import { resetTestDb } from '../helpers/test-db';
|
import { resetTestDb } from '../helpers/test-db';
|
||||||
import { createUser } from '../helpers/factories';
|
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
|
||||||
import { authCookie } from '../helpers/auth';
|
import { authCookie } from '../helpers/auth';
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||||
|
|
||||||
@@ -154,6 +155,137 @@ describe('In-app notifications', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// New preferences matrix API (NROUTE series)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('GET /api/notifications/preferences — matrix format', () => {
|
||||||
|
it('NROUTE-002 — returns preferences, available_channels, event_types, implemented_combos', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('preferences');
|
||||||
|
expect(res.body).toHaveProperty('available_channels');
|
||||||
|
expect(res.body).toHaveProperty('event_types');
|
||||||
|
expect(res.body).toHaveProperty('implemented_combos');
|
||||||
|
expect(res.body.available_channels.inapp).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-003 — regular user does not see version_available in event_types', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.event_types).not.toContain('version_available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-004 — user preferences endpoint excludes version_available even for admins', async () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.event_types).not.toContain('version_available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-004b — admin notification preferences endpoint returns version_available', async () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/admin/notification-preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.event_types).toContain('version_available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-005 — all preferences default to true for new user with no stored prefs', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const { preferences } = res.body;
|
||||||
|
for (const [, channels] of Object.entries(preferences)) {
|
||||||
|
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
|
||||||
|
expect(enabled).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/notifications/preferences — matrix format', () => {
|
||||||
|
it('NROUTE-007 — disabling a preference persists and is reflected in subsequent GET', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const putRes = await request(app)
|
||||||
|
.put('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ trip_invite: { email: false } });
|
||||||
|
|
||||||
|
expect(putRes.status).toBe(200);
|
||||||
|
expect(putRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||||
|
|
||||||
|
const getRes = await request(app)
|
||||||
|
.get('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-008 — re-enabling a preference restores default state', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ trip_invite: { email: true } });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.preferences['trip_invite']['email']).toBe(true);
|
||||||
|
|
||||||
|
const row = testDb.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(user.id, 'trip_invite', 'email');
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-009 — partial update does not affect other preferences', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
disableNotificationPref(testDb, user.id, 'booking_change', 'email');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ trip_invite: { email: false } });
|
||||||
|
|
||||||
|
const getRes = await request(app)
|
||||||
|
.get('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(getRes.body.preferences['booking_change']['email']).toBe(false);
|
||||||
|
expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||||
|
expect(getRes.body.preferences['trip_reminder']['email']).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('implemented_combos — in-app channel coverage', () => {
|
||||||
|
it('NROUTE-010 — implemented_combos includes inapp for all event types', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const { implemented_combos } = res.body as { implemented_combos: Record<string, string[]> };
|
||||||
|
const eventTypes = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
|
||||||
|
for (const event of eventTypes) {
|
||||||
|
expect(implemented_combos[event], `${event} should support inapp`).toContain('inapp');
|
||||||
|
expect(implemented_combos[event], `${event} should support email`).toContain('email');
|
||||||
|
expect(implemented_combos[event], `${event} should support webhook`).toContain('webhook');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Notification test endpoints', () => {
|
describe('Notification test endpoints', () => {
|
||||||
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
|
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
@@ -165,13 +297,244 @@ describe('Notification test endpoints', () => {
|
|||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('NOTIF-006 — POST /api/notifications/test-webhook requires admin', async () => {
|
it('NOTIF-006 — POST /api/notifications/test-webhook returns 400 when url is missing', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/notifications/test-webhook')
|
.post('/api/notifications/test-webhook')
|
||||||
.set('Cookie', authCookie(user.id))
|
.set('Cookie', authCookie(user.id))
|
||||||
.send({});
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOTIF-006b — POST /api/notifications/test-webhook returns 400 for invalid URL', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/notifications/test-webhook')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: 'not-a-url' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helper: insert a boolean notification directly into the DB
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function insertBooleanNotification(recipientId: number): number {
|
||||||
|
const result = testDb.prepare(`
|
||||||
|
INSERT INTO notifications (
|
||||||
|
type, scope, target, sender_id, recipient_id,
|
||||||
|
title_key, title_params, text_key, text_params,
|
||||||
|
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||||
|
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||||
|
'notif.action.accept', 'notif.action.decline',
|
||||||
|
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||||
|
)
|
||||||
|
`).run(recipientId, recipientId);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSimpleNotification(recipientId: number): number {
|
||||||
|
const result = testDb.prepare(`
|
||||||
|
INSERT INTO notifications (
|
||||||
|
type, scope, target, sender_id, recipient_id,
|
||||||
|
title_key, title_params, text_key, text_params
|
||||||
|
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||||
|
`).run(recipientId, recipientId);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// POST /in-app/:id/respond
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('POST /api/notifications/in-app/:id/respond', () => {
|
||||||
|
it('NROUTE-011 — valid positive response returns success and updated notification', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertBooleanNotification(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/notifications/in-app/${id}/respond`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ response: 'positive' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.notification).toBeDefined();
|
||||||
|
expect(res.body.notification.response).toBe('positive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-012 — invalid response value returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertBooleanNotification(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/notifications/in-app/${id}/respond`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ response: 'maybe' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-013 — response on non-existent notification returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/notifications/in-app/99999/respond')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ response: 'positive' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-014 — double response returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertBooleanNotification(user.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/notifications/in-app/${id}/respond`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ response: 'positive' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/notifications/in-app/${id}/respond`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ response: 'negative' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/already responded/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// PUT /api/admin/notification-preferences
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PUT /api/admin/notification-preferences', () => {
|
||||||
|
it('NROUTE-015 — admin can disable email for version_available, persists in GET', async () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
|
||||||
|
const putRes = await request(app)
|
||||||
|
.put('/api/admin/notification-preferences')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ version_available: { email: false } });
|
||||||
|
|
||||||
|
expect(putRes.status).toBe(200);
|
||||||
|
expect(putRes.body.preferences['version_available']['email']).toBe(false);
|
||||||
|
|
||||||
|
const getRes = await request(app)
|
||||||
|
.get('/api/admin/notification-preferences')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
expect(getRes.body.preferences['version_available']['email']).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-016 — non-admin is rejected with 403', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/notification-preferences')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ version_available: { email: false } });
|
||||||
|
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// In-app CRUD with actual notification data
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('In-app notifications — CRUD with data', () => {
|
||||||
|
it('NROUTE-017 — GET /in-app returns created notifications', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
insertSimpleNotification(user.id);
|
||||||
|
insertSimpleNotification(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/in-app')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.notifications.length).toBe(2);
|
||||||
|
expect(res.body.total).toBe(2);
|
||||||
|
expect(res.body.unread_count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-018 — unread count reflects actual unread notifications', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
insertSimpleNotification(user.id);
|
||||||
|
insertSimpleNotification(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/in-app/unread-count')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-019 — mark-read on existing notification succeeds and decrements unread count', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertSimpleNotification(user.id);
|
||||||
|
|
||||||
|
const markRes = await request(app)
|
||||||
|
.put(`/api/notifications/in-app/${id}/read`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(markRes.status).toBe(200);
|
||||||
|
expect(markRes.body.success).toBe(true);
|
||||||
|
|
||||||
|
const countRes = await request(app)
|
||||||
|
.get('/api/notifications/in-app/unread-count')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(countRes.body.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-020 — mark-unread on a read notification succeeds', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertSimpleNotification(user.id);
|
||||||
|
// Mark read first
|
||||||
|
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/notifications/in-app/${id}/unread`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(id) as { is_read: number };
|
||||||
|
expect(row.is_read).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-021 — DELETE on existing notification removes it', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertSimpleNotification(user.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/notifications/in-app/${id}`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
const row = testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(id);
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NROUTE-022 — unread_only=true filter returns only unread notifications', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id1 = insertSimpleNotification(user.id);
|
||||||
|
insertSimpleNotification(user.id);
|
||||||
|
// Mark first one read
|
||||||
|
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id1);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notifications/in-app?unread_only=true')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.notifications.length).toBe(1);
|
||||||
|
expect(res.body.notifications[0].is_read).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
294
server/tests/unit/services/inAppNotificationPrefs.test.ts
Normal file
294
server/tests/unit/services/inAppNotificationPrefs.test.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for in-app notification preference filtering in createNotification().
|
||||||
|
* Covers INOTIF-001 to INOTIF-004.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: () => null,
|
||||||
|
canAccessTrip: () => null,
|
||||||
|
isOwner: () => false,
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock WebSocket broadcast — must use vi.hoisted() so broadcastMock is available
|
||||||
|
// when the vi.mock factory is evaluated (factories are hoisted before const declarations)
|
||||||
|
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||||
|
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||||
|
|
||||||
|
import { createTables } from '../../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
|
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
|
||||||
|
import { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
broadcastMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// createNotification — preference filtering
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('createNotification — preference filtering', () => {
|
||||||
|
it('INOTIF-001 — notification without event_type is delivered to all recipients (backward compat)', () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
const { user: recipient } = createUser(testDb);
|
||||||
|
// The admin scope targets all admins — create a second admin as the sender
|
||||||
|
const { user: sender } = createAdmin(testDb);
|
||||||
|
|
||||||
|
// Send to a specific user (user scope) without event_type
|
||||||
|
const ids = createNotification({
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'user',
|
||||||
|
target: recipient.id,
|
||||||
|
sender_id: sender.id,
|
||||||
|
title_key: 'notifications.test.title',
|
||||||
|
text_key: 'notifications.test.text',
|
||||||
|
// no event_type
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ids.length).toBe(1);
|
||||||
|
const row = testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
// Also verify the admin who disabled all prefs still gets messages without event_type
|
||||||
|
disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp');
|
||||||
|
// admin still gets this since no event_type check
|
||||||
|
const adminIds = createNotification({
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'user',
|
||||||
|
target: admin.id,
|
||||||
|
sender_id: sender.id,
|
||||||
|
title_key: 'notifications.test.title',
|
||||||
|
text_key: 'notifications.test.text',
|
||||||
|
});
|
||||||
|
expect(adminIds.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-002 — notification with event_type skips recipients who have disabled that event on inapp', () => {
|
||||||
|
const { user: sender } = createAdmin(testDb);
|
||||||
|
const { user: recipient1 } = createUser(testDb);
|
||||||
|
const { user: recipient2 } = createUser(testDb);
|
||||||
|
|
||||||
|
// recipient2 has disabled inapp for trip_invite
|
||||||
|
disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp');
|
||||||
|
|
||||||
|
// Use a trip to target both members
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number;
|
||||||
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
|
||||||
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);
|
||||||
|
|
||||||
|
const ids = createNotification({
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'trip',
|
||||||
|
target: tripId,
|
||||||
|
sender_id: sender.id,
|
||||||
|
event_type: 'trip_invite',
|
||||||
|
title_key: 'notifications.test.title',
|
||||||
|
text_key: 'notifications.test.text',
|
||||||
|
});
|
||||||
|
|
||||||
|
// sender excluded, recipient1 included, recipient2 skipped (disabled pref)
|
||||||
|
expect(ids.length).toBe(1);
|
||||||
|
const r1 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient1.id);
|
||||||
|
const r2 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient2.id);
|
||||||
|
expect(r1).toBeDefined();
|
||||||
|
expect(r2).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', () => {
|
||||||
|
const { user: sender } = createAdmin(testDb);
|
||||||
|
const { user: recipient } = createUser(testDb);
|
||||||
|
|
||||||
|
// No preferences stored for recipient — should default to enabled
|
||||||
|
const ids = createNotification({
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'user',
|
||||||
|
target: recipient.id,
|
||||||
|
sender_id: sender.id,
|
||||||
|
event_type: 'trip_invite',
|
||||||
|
title_key: 'notifications.test.title',
|
||||||
|
text_key: 'notifications.test.text',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ids.length).toBe(1);
|
||||||
|
const row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', () => {
|
||||||
|
const { user: sender } = createAdmin(testDb);
|
||||||
|
const { user: recipient } = createUser(testDb);
|
||||||
|
|
||||||
|
const id = createNotificationForRecipient(
|
||||||
|
{
|
||||||
|
type: 'navigate',
|
||||||
|
scope: 'user',
|
||||||
|
target: recipient.id,
|
||||||
|
sender_id: sender.id,
|
||||||
|
event_type: 'trip_invite',
|
||||||
|
title_key: 'notif.trip_invite.title',
|
||||||
|
text_key: 'notif.trip_invite.text',
|
||||||
|
navigate_text_key: 'notif.action.view_trip',
|
||||||
|
navigate_target: '/trips/99',
|
||||||
|
},
|
||||||
|
recipient.id,
|
||||||
|
{ username: 'admin', avatar: null }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(id).toBeTypeOf('number');
|
||||||
|
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined;
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.recipient_id).toBe(recipient.id);
|
||||||
|
expect(row!.navigate_target).toBe('/trips/99');
|
||||||
|
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(broadcastMock.mock.calls[0][0]).toBe(recipient.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-004 — admin-scope version_available only reaches admins with enabled pref', () => {
|
||||||
|
const { user: admin1 } = createAdmin(testDb);
|
||||||
|
const { user: admin2 } = createAdmin(testDb);
|
||||||
|
|
||||||
|
// admin2 disables version_available inapp notifications
|
||||||
|
disableNotificationPref(testDb, admin2.id, 'version_available', 'inapp');
|
||||||
|
|
||||||
|
const ids = createNotification({
|
||||||
|
type: 'navigate',
|
||||||
|
scope: 'admin',
|
||||||
|
target: 0,
|
||||||
|
sender_id: null,
|
||||||
|
event_type: 'version_available',
|
||||||
|
title_key: 'notifications.versionAvailable.title',
|
||||||
|
text_key: 'notifications.versionAvailable.text',
|
||||||
|
navigate_text_key: 'notifications.versionAvailable.button',
|
||||||
|
navigate_target: '/admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only admin1 should receive it
|
||||||
|
expect(ids.length).toBe(1);
|
||||||
|
const admin1Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin1.id);
|
||||||
|
const admin2Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin2.id);
|
||||||
|
expect(admin1Row).toBeDefined();
|
||||||
|
expect(admin2Row).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// respondToBoolean
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function insertBooleanNotification(recipientId: number, senderId: number | null = null): number {
|
||||||
|
const result = testDb.prepare(`
|
||||||
|
INSERT INTO notifications (
|
||||||
|
type, scope, target, sender_id, recipient_id,
|
||||||
|
title_key, title_params, text_key, text_params,
|
||||||
|
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||||
|
) VALUES ('boolean', 'user', ?, ?, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||||
|
'notif.action.accept', 'notif.action.decline',
|
||||||
|
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||||
|
)
|
||||||
|
`).run(recipientId, senderId, recipientId);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSimpleNotification(recipientId: number): number {
|
||||||
|
const result = testDb.prepare(`
|
||||||
|
INSERT INTO notifications (
|
||||||
|
type, scope, target, sender_id, recipient_id,
|
||||||
|
title_key, title_params, text_key, text_params
|
||||||
|
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||||
|
`).run(recipientId, recipientId);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('respondToBoolean', () => {
|
||||||
|
it('INOTIF-005 — positive response sets response=positive, marks read, broadcasts update', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertBooleanNotification(user.id);
|
||||||
|
|
||||||
|
const result = await respondToBoolean(id, user.id, 'positive');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.notification).toBeDefined();
|
||||||
|
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as any;
|
||||||
|
expect(row.response).toBe('positive');
|
||||||
|
expect(row.is_read).toBe(1);
|
||||||
|
expect(broadcastMock).toHaveBeenCalledWith(user.id, expect.objectContaining({ type: 'notification:updated' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-006 — negative response sets response=negative', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertBooleanNotification(user.id);
|
||||||
|
|
||||||
|
const result = await respondToBoolean(id, user.id, 'negative');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const row = testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(id) as any;
|
||||||
|
expect(row.response).toBe('negative');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-007 — double-response prevention returns error on second call', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertBooleanNotification(user.id);
|
||||||
|
|
||||||
|
await respondToBoolean(id, user.id, 'positive');
|
||||||
|
const result = await respondToBoolean(id, user.id, 'negative');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toMatch(/already responded/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-008 — response on a simple notification returns error', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const id = insertSimpleNotification(user.id);
|
||||||
|
|
||||||
|
const result = await respondToBoolean(id, user.id, 'positive');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toMatch(/not a boolean/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-009 — response on a non-existent notification returns error', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const result = await respondToBoolean(99999, user.id, 'positive');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INOTIF-010 — response on notification belonging to another user returns error', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
const id = insertBooleanNotification(owner.id);
|
||||||
|
|
||||||
|
const result = await respondToBoolean(id, other.id, 'positive');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
234
server/tests/unit/services/migration.test.ts
Normal file
234
server/tests/unit/services/migration.test.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for migration 69 (normalized notification preferences).
|
||||||
|
* Covers MIGR-001 to MIGR-004.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { createTables } from '../../../src/db/schema';
|
||||||
|
|
||||||
|
function buildFreshDb() {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all migrations up to (but NOT including) migration 69, then return the db.
|
||||||
|
* This allows us to set up old-schema data and test that migration 69 handles it.
|
||||||
|
*
|
||||||
|
* We do this by running only the schema tables that existed before migration 69,
|
||||||
|
* seeding old data, then running migration 69 in isolation.
|
||||||
|
*/
|
||||||
|
function setupPreMigration69Db() {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
// Create schema_version and users table (bare minimum)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0);
|
||||||
|
INSERT INTO schema_version (version) VALUES (0);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
notify_trip_invite INTEGER DEFAULT 1,
|
||||||
|
notify_booking_change INTEGER DEFAULT 1,
|
||||||
|
notify_trip_reminder INTEGER DEFAULT 1,
|
||||||
|
notify_vacay_invite INTEGER DEFAULT 1,
|
||||||
|
notify_photos_shared INTEGER DEFAULT 1,
|
||||||
|
notify_collab_message INTEGER DEFAULT 1,
|
||||||
|
notify_packing_tagged INTEGER DEFAULT 1,
|
||||||
|
notify_webhook INTEGER DEFAULT 1,
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and run only migration 69 (index 68) from the migrations array.
|
||||||
|
* We do this by importing migrations and calling the last one directly.
|
||||||
|
*/
|
||||||
|
function runMigration69(db: ReturnType<typeof Database>): void {
|
||||||
|
// Migration 69 logic extracted inline for isolation
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (user_id, event_type, channel)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const oldPrefs = db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>;
|
||||||
|
const eventCols: Record<string, string> = {
|
||||||
|
trip_invite: 'notify_trip_invite',
|
||||||
|
booking_change: 'notify_booking_change',
|
||||||
|
trip_reminder: 'notify_trip_reminder',
|
||||||
|
vacay_invite: 'notify_vacay_invite',
|
||||||
|
photos_shared: 'notify_photos_shared',
|
||||||
|
collab_message: 'notify_collab_message',
|
||||||
|
packing_tagged: 'notify_packing_tagged',
|
||||||
|
};
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
|
||||||
|
for (const [userId, eventType, channel, enabled] of rows) {
|
||||||
|
insert.run(userId, eventType, channel, enabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of oldPrefs) {
|
||||||
|
const userId = row.user_id as number;
|
||||||
|
const webhookEnabled = (row.notify_webhook as number) ?? 0;
|
||||||
|
const rows: Array<[number, string, string, number]> = [];
|
||||||
|
for (const [eventType, col] of Object.entries(eventCols)) {
|
||||||
|
const emailEnabled = (row[col] as number) ?? 1;
|
||||||
|
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
|
||||||
|
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
|
||||||
|
}
|
||||||
|
if (rows.length > 0) insertMany(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
INSERT OR IGNORE INTO app_settings (key, value)
|
||||||
|
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Migration 69 tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Migration 69 — normalized notification_channel_preferences', () => {
|
||||||
|
it('MIGR-001 — notification_channel_preferences table exists after migration', () => {
|
||||||
|
const db = setupPreMigration69Db();
|
||||||
|
runMigration69(db);
|
||||||
|
|
||||||
|
const table = db.prepare(
|
||||||
|
`SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'`
|
||||||
|
).get();
|
||||||
|
expect(table).toBeDefined();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MIGR-002 — old notification_preferences rows with disabled events migrated as enabled=0', () => {
|
||||||
|
const db = setupPreMigration69Db();
|
||||||
|
|
||||||
|
// Create a user
|
||||||
|
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('testuser', 'hash', 'user')).lastInsertRowid as number;
|
||||||
|
|
||||||
|
// Simulate user who has disabled trip_invite and booking_change email
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO notification_preferences
|
||||||
|
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
|
||||||
|
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
|
||||||
|
VALUES (?, 0, 0, 1, 1, 1, 1, 1, 1)
|
||||||
|
`).run(userId);
|
||||||
|
|
||||||
|
runMigration69(db);
|
||||||
|
|
||||||
|
const tripInviteEmail = db.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(userId, 'trip_invite', 'email') as { enabled: number } | undefined;
|
||||||
|
const bookingEmail = db.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(userId, 'booking_change', 'email') as { enabled: number } | undefined;
|
||||||
|
const reminderEmail = db.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined;
|
||||||
|
|
||||||
|
// Disabled events should have enabled=0 rows
|
||||||
|
expect(tripInviteEmail).toBeDefined();
|
||||||
|
expect(tripInviteEmail!.enabled).toBe(0);
|
||||||
|
expect(bookingEmail).toBeDefined();
|
||||||
|
expect(bookingEmail!.enabled).toBe(0);
|
||||||
|
// Enabled events should have no row (no-row = enabled)
|
||||||
|
expect(reminderEmail).toBeUndefined();
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MIGR-003 — old notify_webhook=0 creates disabled webhook rows for all 7 events', () => {
|
||||||
|
const db = setupPreMigration69Db();
|
||||||
|
|
||||||
|
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('webhookuser', 'hash', 'user')).lastInsertRowid as number;
|
||||||
|
|
||||||
|
// User has all email enabled but webhook disabled
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO notification_preferences
|
||||||
|
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
|
||||||
|
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
|
||||||
|
VALUES (?, 1, 1, 1, 1, 1, 1, 1, 0)
|
||||||
|
`).run(userId);
|
||||||
|
|
||||||
|
runMigration69(db);
|
||||||
|
|
||||||
|
const allEvents = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
|
||||||
|
for (const eventType of allEvents) {
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(userId, eventType, 'webhook') as { enabled: number } | undefined;
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.enabled).toBe(0);
|
||||||
|
|
||||||
|
// Email rows should NOT exist (all email was enabled → no row needed)
|
||||||
|
const emailRow = db.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(userId, eventType, 'email');
|
||||||
|
expect(emailRow).toBeUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MIGR-004 — notification_channels key is created in app_settings from notification_channel value', () => {
|
||||||
|
const db = setupPreMigration69Db();
|
||||||
|
|
||||||
|
// Simulate existing single-channel setting
|
||||||
|
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
|
||||||
|
|
||||||
|
runMigration69(db);
|
||||||
|
|
||||||
|
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
|
||||||
|
expect(plural).toBeDefined();
|
||||||
|
expect(plural!.value).toBe('email');
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MIGR-004b — notification_channels is not duplicated if already exists', () => {
|
||||||
|
const db = setupPreMigration69Db();
|
||||||
|
|
||||||
|
// Both keys already set (e.g. partial migration or manual edit)
|
||||||
|
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
|
||||||
|
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channels', 'email,webhook');
|
||||||
|
|
||||||
|
runMigration69(db);
|
||||||
|
|
||||||
|
// The existing notification_channels value should be preserved (INSERT OR IGNORE)
|
||||||
|
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
|
||||||
|
expect(plural!.value).toBe('email,webhook');
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for notificationPreferencesService.
|
||||||
|
* Covers NPREF-001 to NPREF-021.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: () => null,
|
||||||
|
canAccessTrip: () => null,
|
||||||
|
isOwner: () => false,
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||||
|
decrypt_api_key: (v: string | null) => v,
|
||||||
|
maybe_encrypt_api_key: (v: string) => v,
|
||||||
|
encrypt_api_key: (v: string) => v,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createTables } from '../../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
|
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
|
||||||
|
import {
|
||||||
|
isEnabledForEvent,
|
||||||
|
getPreferencesMatrix,
|
||||||
|
setPreferences,
|
||||||
|
setAdminPreferences,
|
||||||
|
getAdminGlobalPref,
|
||||||
|
getActiveChannels,
|
||||||
|
getAvailableChannels,
|
||||||
|
} from '../../../src/services/notificationPreferencesService';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// isEnabledForEvent
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('isEnabledForEvent', () => {
|
||||||
|
it('NPREF-001 — returns true when no row exists (default enabled)', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-002 — returns true when row exists with enabled=1', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
testDb.prepare(
|
||||||
|
'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)'
|
||||||
|
).run(user.id, 'trip_invite', 'email');
|
||||||
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-003 — returns false when row exists with enabled=0', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||||
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// getPreferencesMatrix
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getPreferencesMatrix', () => {
|
||||||
|
it('NPREF-004 — regular user does not see version_available in event_types', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { event_types } = getPreferencesMatrix(user.id, 'user');
|
||||||
|
expect(event_types).not.toContain('version_available');
|
||||||
|
expect(event_types.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-005 — user scope excludes version_available for everyone including admins', () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user');
|
||||||
|
expect(event_types).not.toContain('version_available');
|
||||||
|
expect(event_types.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-005b — admin scope returns only version_available', () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||||
|
expect(event_types).toContain('version_available');
|
||||||
|
expect(event_types.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-006 — returns default true for all preferences when no stored prefs', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { preferences } = getPreferencesMatrix(user.id, 'user');
|
||||||
|
for (const [, channels] of Object.entries(preferences)) {
|
||||||
|
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
|
||||||
|
expect(enabled).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-007 — reflects stored disabled preferences in the matrix', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||||
|
disableNotificationPref(testDb, user.id, 'collab_message', 'webhook');
|
||||||
|
const { preferences } = getPreferencesMatrix(user.id, 'user');
|
||||||
|
expect(preferences['trip_invite']!['email']).toBe(false);
|
||||||
|
expect(preferences['collab_message']!['webhook']).toBe(false);
|
||||||
|
// Others unaffected
|
||||||
|
expect(preferences['trip_invite']!['webhook']).toBe(true);
|
||||||
|
expect(preferences['booking_change']!['email']).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-008 — available_channels.inapp is always true', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||||
|
expect(available_channels.inapp).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-009 — available_channels.email is true when email is in notification_channels', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setNotificationChannels(testDb, 'email');
|
||||||
|
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||||
|
expect(available_channels.email).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-010 — available_channels.email is false when email is not in notification_channels', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
// No notification_channels set → defaults to none
|
||||||
|
const { available_channels } = getPreferencesMatrix(user.id, 'user');
|
||||||
|
expect(available_channels.email).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||||
|
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']);
|
||||||
|
// All events now support all three channels
|
||||||
|
expect(implemented_combos['trip_invite']).toContain('inapp');
|
||||||
|
expect(implemented_combos['trip_invite']).toContain('email');
|
||||||
|
expect(implemented_combos['trip_invite']).toContain('webhook');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// setPreferences
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('setPreferences', () => {
|
||||||
|
it('NPREF-012 — disabling a preference inserts a row with enabled=0', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setPreferences(user.id, { trip_invite: { email: false } });
|
||||||
|
const row = testDb.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined;
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.enabled).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-013 — re-enabling a preference removes the disabled row', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
// First disable
|
||||||
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||||
|
// Then re-enable
|
||||||
|
setPreferences(user.id, { trip_invite: { email: true } });
|
||||||
|
const row = testDb.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(user.id, 'trip_invite', 'email');
|
||||||
|
// Row should be deleted — default is enabled
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-014 — bulk update handles multiple event+channel combos', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setPreferences(user.id, {
|
||||||
|
trip_invite: { email: false, webhook: false },
|
||||||
|
booking_change: { email: false },
|
||||||
|
trip_reminder: { webhook: true },
|
||||||
|
});
|
||||||
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
|
||||||
|
expect(isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
|
||||||
|
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
|
||||||
|
// trip_reminder webhook was set to true → no row, default enabled
|
||||||
|
const row = testDb.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(user.id, 'trip_reminder', 'webhook');
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// getActiveChannels
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getActiveChannels', () => {
|
||||||
|
it('NPREF-015 — returns [] when notification_channels is none', () => {
|
||||||
|
setAppSetting(testDb, 'notification_channels', 'none');
|
||||||
|
expect(getActiveChannels()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-016 — returns [email] when notification_channels is email', () => {
|
||||||
|
setAppSetting(testDb, 'notification_channels', 'email');
|
||||||
|
expect(getActiveChannels()).toEqual(['email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', () => {
|
||||||
|
setAppSetting(testDb, 'notification_channels', 'email,webhook');
|
||||||
|
expect(getActiveChannels()).toEqual(['email', 'webhook']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', () => {
|
||||||
|
// Only set the singular key
|
||||||
|
setAppSetting(testDb, 'notification_channel', 'webhook');
|
||||||
|
// No notification_channels key
|
||||||
|
expect(getActiveChannels()).toEqual(['webhook']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// getAvailableChannels
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getAvailableChannels', () => {
|
||||||
|
it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => {
|
||||||
|
setAppSetting(testDb, 'smtp_host', 'mail.example.com');
|
||||||
|
const channels = getAvailableChannels();
|
||||||
|
expect(channels.email).toBe(true);
|
||||||
|
expect(channels.inapp).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-020 — webhook available when admin has enabled the webhook channel', () => {
|
||||||
|
setNotificationChannels(testDb, 'webhook');
|
||||||
|
const channels = getAvailableChannels();
|
||||||
|
expect(channels.webhook).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => {
|
||||||
|
const original = process.env.SMTP_HOST;
|
||||||
|
process.env.SMTP_HOST = 'env-mail.example.com';
|
||||||
|
try {
|
||||||
|
const channels = getAvailableChannels();
|
||||||
|
expect(channels.email).toBe(true);
|
||||||
|
} finally {
|
||||||
|
if (original === undefined) delete process.env.SMTP_HOST;
|
||||||
|
else process.env.SMTP_HOST = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// setAdminPreferences
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('setAdminPreferences', () => {
|
||||||
|
it('NPREF-022 — disabling email for version_available stores global pref in app_settings', () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
setAdminPreferences(user.id, { version_available: { email: false } });
|
||||||
|
expect(getAdminGlobalPref('version_available', 'email')).toBe(false);
|
||||||
|
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
||||||
|
expect(row?.value).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
setAdminPreferences(user.id, { version_available: { inapp: false } });
|
||||||
|
const row = testDb.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined;
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.enabled).toBe(0);
|
||||||
|
// Global app_settings should NOT have an inapp key
|
||||||
|
const globalRow = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_inapp');
|
||||||
|
expect(globalRow).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-024 — re-enabling inapp removes the disabled per-user row', () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
// First disable
|
||||||
|
disableNotificationPref(testDb, user.id, 'version_available', 'inapp');
|
||||||
|
// Then re-enable via setAdminPreferences
|
||||||
|
setAdminPreferences(user.id, { version_available: { inapp: true } });
|
||||||
|
const row = testDb.prepare(
|
||||||
|
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||||
|
).get(user.id, 'version_available', 'inapp');
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NPREF-025 — enabling email stores global pref as "1" in app_settings', () => {
|
||||||
|
const { user } = createAdmin(testDb);
|
||||||
|
// First disable, then re-enable
|
||||||
|
setAdminPreferences(user.id, { version_available: { email: false } });
|
||||||
|
setAdminPreferences(user.id, { version_available: { email: true } });
|
||||||
|
expect(getAdminGlobalPref('version_available', 'email')).toBe(true);
|
||||||
|
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
|
||||||
|
expect(row?.value).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
455
server/tests/unit/services/notificationService.test.ts
Normal file
455
server/tests/unit/services/notificationService.test.ts
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the unified notificationService.send().
|
||||||
|
* Covers NSVC-001 to NSVC-014.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: () => null,
|
||||||
|
canAccessTrip: () => null,
|
||||||
|
isOwner: () => false,
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||||
|
decrypt_api_key: (v: string | null) => v,
|
||||||
|
maybe_encrypt_api_key: (v: string) => v,
|
||||||
|
encrypt_api_key: (v: string) => v,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { sendMailMock, fetchMock, broadcastMock } = vi.hoisted(() => ({
|
||||||
|
sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }),
|
||||||
|
fetchMock: vi.fn(),
|
||||||
|
broadcastMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('nodemailer', () => ({
|
||||||
|
default: {
|
||||||
|
createTransport: vi.fn(() => ({
|
||||||
|
sendMail: sendMailMock,
|
||||||
|
verify: vi.fn().mockResolvedValue(true),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node-fetch', () => ({ default: fetchMock }));
|
||||||
|
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||||
|
|
||||||
|
import { createTables } from '../../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
|
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
|
||||||
|
import { send } from '../../../src/services/notificationService';
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setSmtp(): void {
|
||||||
|
setAppSetting(testDb, 'smtp_host', 'mail.test.com');
|
||||||
|
setAppSetting(testDb, 'smtp_port', '587');
|
||||||
|
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): void {
|
||||||
|
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)").run(userId, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void {
|
||||||
|
setAppSetting(testDb, 'admin_webhook_url', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInAppNotifications(recipientId: number) {
|
||||||
|
return testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ? ORDER BY id').all(recipientId) as Array<{
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
scope: string;
|
||||||
|
navigate_target: string | null;
|
||||||
|
navigate_text_key: string | null;
|
||||||
|
title_key: string;
|
||||||
|
text_key: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countAllNotifications(): number {
|
||||||
|
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
sendMailMock.mockClear();
|
||||||
|
fetchMock.mockClear();
|
||||||
|
broadcastMock.mockClear();
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Multi-channel dispatch
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('send() — multi-channel dispatch', () => {
|
||||||
|
it('NSVC-001 — dispatches to all 3 channels (inapp, email, webhook) when all are active', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSmtp();
|
||||||
|
setUserWebhookUrl(user.id);
|
||||||
|
setNotificationChannels(testDb, 'email,webhook');
|
||||||
|
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(countAllNotifications()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSmtp();
|
||||||
|
setUserWebhookUrl(user.id);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
expect(sendMailMock).not.toHaveBeenCalled();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(countAllNotifications()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-003 — sends only email when only email channel is active', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSmtp();
|
||||||
|
setNotificationChannels(testDb, 'email');
|
||||||
|
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Per-user preference filtering
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('send() — per-user preference filtering', () => {
|
||||||
|
it('NSVC-004 — skips email for a user who disabled trip_invite on email channel', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSmtp();
|
||||||
|
setNotificationChannels(testDb, 'email');
|
||||||
|
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||||
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
expect(sendMailMock).not.toHaveBeenCalled();
|
||||||
|
// in-app still fires
|
||||||
|
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-005 — skips in-app for a user who disabled the event on inapp channel', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
expect(broadcastMock).not.toHaveBeenCalled();
|
||||||
|
expect(countAllNotifications()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSmtp();
|
||||||
|
setUserWebhookUrl(user.id);
|
||||||
|
setNotificationChannels(testDb, 'email,webhook');
|
||||||
|
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||||
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
expect(sendMailMock).not.toHaveBeenCalled();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Recipient resolution
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('send() — recipient resolution', () => {
|
||||||
|
it('NSVC-007 — trip scope sends to owner + members, excludes actorId', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member1 } = createUser(testDb);
|
||||||
|
const { user: member2 } = createUser(testDb);
|
||||||
|
const { user: actor } = createUser(testDb);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
|
||||||
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id);
|
||||||
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id);
|
||||||
|
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id);
|
||||||
|
|
||||||
|
await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
// Owner, member1, member2 get it; actor is excluded
|
||||||
|
expect(countAllNotifications()).toBe(3);
|
||||||
|
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||||
|
expect(recipients).toContain(owner.id);
|
||||||
|
expect(recipients).toContain(member1.id);
|
||||||
|
expect(recipients).toContain(member2.id);
|
||||||
|
expect(recipients).not.toContain(actor.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-008 — user scope sends to exactly one user', async () => {
|
||||||
|
const { user: target } = createUser(testDb);
|
||||||
|
const { user: other } = createUser(testDb);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } });
|
||||||
|
|
||||||
|
expect(countAllNotifications()).toBe(1);
|
||||||
|
const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number };
|
||||||
|
expect(notif.recipient_id).toBe(target.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-009 — admin scope sends to all admins (not regular users)', async () => {
|
||||||
|
const { user: admin1 } = createAdmin(testDb);
|
||||||
|
const { user: admin2 } = createAdmin(testDb);
|
||||||
|
createUser(testDb); // regular user — should NOT receive
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
|
||||||
|
|
||||||
|
expect(countAllNotifications()).toBe(2);
|
||||||
|
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||||
|
expect(recipients).toContain(admin1.id);
|
||||||
|
expect(recipients).toContain(admin2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-010 — admin scope fires admin webhook URL when set', async () => {
|
||||||
|
createAdmin(testDb);
|
||||||
|
setAdminWebhookUrl();
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
|
||||||
|
|
||||||
|
// Wait for fire-and-forget admin webhook
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const callUrl = fetchMock.mock.calls[0][0];
|
||||||
|
expect(callUrl).toBe('https://hooks.test.com/admin-webhook');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-011 — does nothing when there are no recipients', async () => {
|
||||||
|
// Trip with no members, sending as the trip owner (actor excluded from trip scope)
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
expect(countAllNotifications()).toBe(0);
|
||||||
|
expect(broadcastMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// In-app notification content
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('send() — in-app notification content', () => {
|
||||||
|
it('NSVC-012 — creates navigate in-app notification with correct title/text/navigate keys', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } });
|
||||||
|
|
||||||
|
const notifs = getInAppNotifications(user.id);
|
||||||
|
expect(notifs.length).toBe(1);
|
||||||
|
expect(notifs[0].type).toBe('navigate');
|
||||||
|
expect(notifs[0].title_key).toBe('notif.trip_invite.title');
|
||||||
|
expect(notifs[0].text_key).toBe('notif.trip_invite.text');
|
||||||
|
expect(notifs[0].navigate_text_key).toBe('notif.action.view_trip');
|
||||||
|
expect(notifs[0].navigate_target).toBe('/trips/42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-013 — creates simple in-app notification when no navigate target is available', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
// vacay_invite without planId → no navigate target → simple type
|
||||||
|
await send({ event: 'vacay_invite', actorId: null, scope: 'user', targetId: user.id, params: { actor: 'Alice' } });
|
||||||
|
|
||||||
|
const notifs = getInAppNotifications(user.id);
|
||||||
|
expect(notifs.length).toBe(1);
|
||||||
|
expect(notifs[0].type).toBe('simple');
|
||||||
|
expect(notifs[0].navigate_target).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-014 — navigate_target uses /admin for version_available event', async () => {
|
||||||
|
const { user: admin } = createAdmin(testDb);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
|
||||||
|
|
||||||
|
const notifs = getInAppNotifications(admin.id);
|
||||||
|
expect(notifs.length).toBe(1);
|
||||||
|
expect(notifs[0].navigate_target).toBe('/admin');
|
||||||
|
expect(notifs[0].title_key).toBe('notif.version_available.title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Email/webhook link generation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('send() — email/webhook links', () => {
|
||||||
|
it('NSVC-015 — email subject and body are localized per recipient language', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSmtp();
|
||||||
|
setNotificationChannels(testDb, 'email');
|
||||||
|
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||||
|
// Set user language to French
|
||||||
|
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id);
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||||
|
const mailArgs = sendMailMock.mock.calls[0][0];
|
||||||
|
// French title for trip_invite should contain "Invitation"
|
||||||
|
expect(mailArgs.subject).toContain('Invitation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-016 — webhook payload includes link field when navigate target is available', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook');
|
||||||
|
setNotificationChannels(testDb, 'webhook');
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
// Generic webhook — link should contain /trips/55
|
||||||
|
expect(body.link).toContain('/trips/55');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Boolean in-app type
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('send() — boolean in-app type', () => {
|
||||||
|
it('NSVC-017 — creates boolean in-app notification with callbacks when inApp.type override is boolean', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setNotificationChannels(testDb, 'none');
|
||||||
|
|
||||||
|
await send({
|
||||||
|
event: 'trip_invite',
|
||||||
|
actorId: null,
|
||||||
|
scope: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' },
|
||||||
|
inApp: {
|
||||||
|
type: 'boolean',
|
||||||
|
positiveTextKey: 'notif.action.accept',
|
||||||
|
negativeTextKey: 'notif.action.decline',
|
||||||
|
positiveCallback: { action: 'test_approve', payload: { tripId: 1 } },
|
||||||
|
negativeCallback: { action: 'test_deny', payload: { tripId: 1 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifs = getInAppNotifications(user.id);
|
||||||
|
expect(notifs.length).toBe(1);
|
||||||
|
const row = notifs[0] as any;
|
||||||
|
expect(row.type).toBe('boolean');
|
||||||
|
expect(row.positive_callback).toContain('test_approve');
|
||||||
|
expect(row.negative_callback).toContain('test_deny');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Channel failure resilience
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('send() — channel failure resilience', () => {
|
||||||
|
it('NSVC-018 — email failure does not prevent in-app or webhook delivery', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSmtp();
|
||||||
|
setUserWebhookUrl(user.id);
|
||||||
|
setNotificationChannels(testDb, 'email,webhook');
|
||||||
|
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||||
|
|
||||||
|
// Make email throw
|
||||||
|
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
// In-app and webhook still fire despite email failure
|
||||||
|
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(countAllNotifications()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSmtp();
|
||||||
|
setUserWebhookUrl(user.id);
|
||||||
|
setNotificationChannels(testDb, 'email,webhook');
|
||||||
|
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||||
|
|
||||||
|
// Make webhook throw
|
||||||
|
fetchMock.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||||
|
|
||||||
|
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||||
|
|
||||||
|
// In-app and email still fire despite webhook failure
|
||||||
|
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(countAllNotifications()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
server/tests/unit/services/versionNotification.test.ts
Normal file
173
server/tests/unit/services/versionNotification.test.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for checkAndNotifyVersion() in adminService.
|
||||||
|
* Covers VNOTIF-001 to VNOTIF-007.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
const { testDb, dbMock } = vi.hoisted(() => {
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
const mock = {
|
||||||
|
db,
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
getPlaceWithTags: () => null,
|
||||||
|
canAccessTrip: () => null,
|
||||||
|
isOwner: () => false,
|
||||||
|
};
|
||||||
|
return { testDb: db, dbMock: mock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => dbMock);
|
||||||
|
vi.mock('../../../src/config', () => ({
|
||||||
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
|
updateJwtSecret: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||||
|
// Mock MCP to avoid session side-effects
|
||||||
|
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||||
|
|
||||||
|
import { createTables } from '../../../src/db/schema';
|
||||||
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
|
import { createAdmin } from '../../helpers/factories';
|
||||||
|
import { checkAndNotifyVersion } from '../../../src/services/adminService';
|
||||||
|
|
||||||
|
// Helper: mock the GitHub releases/latest endpoint
|
||||||
|
function mockGitHubLatest(tagName: string, ok = true): void {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
json: async () => ({ tag_name: tagName, html_url: `https://github.com/mauriceboe/TREK/releases/tag/${tagName}` }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockGitHubFetchFailure(): void {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastNotifiedVersion(): string | undefined {
|
||||||
|
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationCount(): number {
|
||||||
|
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
createTables(testDb);
|
||||||
|
runMigrations(testDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(testDb);
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testDb.close();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// checkAndNotifyVersion
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('checkAndNotifyVersion', () => {
|
||||||
|
it('VNOTIF-001 — does nothing when no update is available', async () => {
|
||||||
|
createAdmin(testDb);
|
||||||
|
// GitHub reports same version as package.json (or older) → update_available: false
|
||||||
|
const { version } = require('../../../package.json');
|
||||||
|
mockGitHubLatest(`v${version}`);
|
||||||
|
|
||||||
|
await checkAndNotifyVersion();
|
||||||
|
|
||||||
|
expect(getNotificationCount()).toBe(0);
|
||||||
|
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => {
|
||||||
|
const { user: admin1 } = createAdmin(testDb);
|
||||||
|
const { user: admin2 } = createAdmin(testDb);
|
||||||
|
mockGitHubLatest('v99.0.0');
|
||||||
|
|
||||||
|
await checkAndNotifyVersion();
|
||||||
|
|
||||||
|
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{ recipient_id: number; type: string; scope: string }>;
|
||||||
|
expect(notifications.length).toBe(2);
|
||||||
|
const recipientIds = notifications.map(n => n.recipient_id);
|
||||||
|
expect(recipientIds).toContain(admin1.id);
|
||||||
|
expect(recipientIds).toContain(admin2.id);
|
||||||
|
expect(notifications[0].type).toBe('navigate');
|
||||||
|
expect(notifications[0].scope).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VNOTIF-003 — sets last_notified_version in app_settings after notifying', async () => {
|
||||||
|
createAdmin(testDb);
|
||||||
|
mockGitHubLatest('v99.1.0');
|
||||||
|
|
||||||
|
await checkAndNotifyVersion();
|
||||||
|
|
||||||
|
expect(getLastNotifiedVersion()).toBe('99.1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => {
|
||||||
|
createAdmin(testDb);
|
||||||
|
mockGitHubLatest('v99.2.0');
|
||||||
|
|
||||||
|
// First call notifies
|
||||||
|
await checkAndNotifyVersion();
|
||||||
|
const countAfterFirst = getNotificationCount();
|
||||||
|
expect(countAfterFirst).toBe(1);
|
||||||
|
|
||||||
|
// Second call with same version — should not create another
|
||||||
|
await checkAndNotifyVersion();
|
||||||
|
expect(getNotificationCount()).toBe(countAfterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
|
||||||
|
createAdmin(testDb);
|
||||||
|
// Simulate having been notified about an older version
|
||||||
|
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', '98.0.0');
|
||||||
|
mockGitHubLatest('v99.3.0');
|
||||||
|
|
||||||
|
await checkAndNotifyVersion();
|
||||||
|
|
||||||
|
expect(getNotificationCount()).toBe(1);
|
||||||
|
expect(getLastNotifiedVersion()).toBe('99.3.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => {
|
||||||
|
createAdmin(testDb);
|
||||||
|
mockGitHubLatest('v99.4.0');
|
||||||
|
|
||||||
|
await checkAndNotifyVersion();
|
||||||
|
|
||||||
|
const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as {
|
||||||
|
type: string;
|
||||||
|
scope: string;
|
||||||
|
navigate_target: string;
|
||||||
|
title_key: string;
|
||||||
|
text_key: string;
|
||||||
|
navigate_text_key: string;
|
||||||
|
};
|
||||||
|
expect(notif.type).toBe('navigate');
|
||||||
|
expect(notif.scope).toBe('admin');
|
||||||
|
expect(notif.navigate_target).toBe('/admin');
|
||||||
|
expect(notif.title_key).toBe('notif.version_available.title');
|
||||||
|
expect(notif.text_key).toBe('notif.version_available.text');
|
||||||
|
expect(notif.navigate_text_key).toBe('notif.action.view_admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VNOTIF-007 — silently handles GitHub API fetch failure (no crash, no notification)', async () => {
|
||||||
|
createAdmin(testDb);
|
||||||
|
mockGitHubFetchFailure();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(checkAndNotifyVersion()).resolves.toBeUndefined();
|
||||||
|
expect(getNotificationCount()).toBe(0);
|
||||||
|
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user