diff --git a/client/src/App.tsx b/client/src/App.tsx
index 6a434f6..c1b1be6 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -71,18 +71,19 @@ function RootRedirect() {
}
export default function App() {
- const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa } = useAuthStore()
+ const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
loadUser()
}
- authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean }) => {
+ authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
+ if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version')
diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx
index e8c7808..b851f57 100644
--- a/client/src/components/Trips/TripFormModal.tsx
+++ b/client/src/components/Trips/TripFormModal.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
-import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
+import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
import { tripsApi, authApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
@@ -23,13 +23,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const toast = useToast()
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
+ const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
+ const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
const [formData, setFormData] = useState({
title: '',
description: '',
start_date: '',
end_date: '',
+ reminder_days: 0 as number,
})
+ const [customReminder, setCustomReminder] = useState(false)
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null)
@@ -41,25 +45,40 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
useEffect(() => {
if (trip) {
+ const rd = trip.reminder_days ?? 3
setFormData({
title: trip.title || '',
description: trip.description || '',
start_date: trip.start_date || '',
end_date: trip.end_date || '',
+ reminder_days: rd,
})
+ setCustomReminder(![0, 1, 3, 9].includes(rd))
setCoverPreview(trip.cover_image || null)
} else {
- setFormData({ title: '', description: '', start_date: '', end_date: '' })
+ setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0 })
+ setCustomReminder(false)
setCoverPreview(null)
}
setPendingCoverFile(null)
setSelectedMembers([])
setError('')
+ if (isOpen) {
+ authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
+ if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
+ }).catch(() => {})
+ }
if (!trip) {
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
}
}, [trip, isOpen])
+ useEffect(() => {
+ if (!trip && isOpen) {
+ setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
+ }
+ }, [tripRemindersEnabled])
+
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@@ -74,6 +93,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
+ reminder_days: formData.reminder_days,
})
// Add selected members for newly created trips
if (selectedMembers.length > 0 && result?.trip?.id) {
@@ -272,6 +292,59 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
+ {/* Reminder — only visible to owner (or when creating) */}
+ {(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
+
+
+ {!tripRemindersEnabled ? (
+
+ {t('trips.reminderDisabledHint')}
+
+ ) : (
+ <>
+
+ {[
+ { value: 0, label: t('trips.reminderNone') },
+ { value: 1, label: `1 ${t('trips.reminderDay')}` },
+ { value: 3, label: `3 ${t('trips.reminderDays')}` },
+ { value: 9, label: `9 ${t('trips.reminderDays')}` },
+ ].map(opt => (
+
+ ))}
+
+
+ {customReminder && (
+
+ update('reminder_days', Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
+ className="w-20 px-3 py-1.5 border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-300" />
+ {t('trips.reminderDaysBefore')}
+
+ )}
+ >
+ )}
+
+ )}
+
{/* Members — only for new trips */}
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
@@ -312,11 +385,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
)}
- {!formData.start_date && !formData.end_date && (
-
- {t('dashboard.noDateHint')}
-
- )}
)
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index 8fca08d..068cae2 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -30,6 +30,13 @@ const ar: Record = {
'common.password': 'كلمة المرور',
'common.saving': 'جارٍ الحفظ...',
'common.saved': 'تم الحفظ',
+ 'trips.reminder': 'تذكير',
+ 'trips.reminderNone': 'بدون',
+ 'trips.reminderDay': 'يوم',
+ 'trips.reminderDays': 'أيام',
+ 'trips.reminderCustom': 'مخصص',
+ 'trips.reminderDaysBefore': 'أيام قبل المغادرة',
+ 'trips.reminderDisabledHint': 'تذكيرات الرحلة معطلة. قم بتفعيلها من الإدارة > الإعدادات > الإشعارات.',
'common.update': 'تحديث',
'common.change': 'تغيير',
'common.uploading': 'جارٍ الرفع...',
@@ -165,6 +172,7 @@ const ar: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'أحداث الإشعارات',
'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
+ 'admin.notifications.configureFirst': 'قم بتكوين إعدادات SMTP أو Webhook أدناه أولاً، ثم قم بتفعيل الأحداث.',
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
@@ -173,6 +181,7 @@ const ar: Record = {
'admin.smtp.title': 'البريد والإشعارات',
'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
'admin.smtp.testButton': 'إرسال بريد تجريبي',
+ 'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index 01b9830..ef333d9 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -26,6 +26,13 @@ const br: Record = {
'common.password': 'Senha',
'common.saving': 'Salvando...',
'common.saved': 'Salvo',
+ 'trips.reminder': 'Lembrete',
+ 'trips.reminderNone': 'Nenhum',
+ 'trips.reminderDay': 'dia',
+ 'trips.reminderDays': 'dias',
+ 'trips.reminderCustom': 'Personalizado',
+ 'trips.reminderDaysBefore': 'dias antes da partida',
+ 'trips.reminderDisabledHint': 'Os lembretes de viagem estão desativados. Ative-os em Admin > Configurações > Notificações.',
'common.update': 'Atualizar',
'common.change': 'Alterar',
'common.uploading': 'Enviando…',
@@ -160,6 +167,7 @@ const br: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificação',
'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
+ 'admin.notifications.configureFirst': 'Configure primeiro as configurações SMTP ou webhook abaixo, depois ative os eventos.',
'admin.notifications.save': 'Salvar configurações de notificação',
'admin.notifications.saved': 'Configurações de notificação salvas',
'admin.notifications.testWebhook': 'Enviar webhook de teste',
@@ -168,6 +176,7 @@ const br: Record = {
'admin.smtp.title': 'E-mail e notificações',
'admin.smtp.hint': 'Configuração SMTP para envio de notificações por e-mail.',
'admin.smtp.testButton': 'Enviar e-mail de teste',
+ 'admin.webhook.hint': 'Enviar notificações para um webhook externo (Discord, Slack, etc.).',
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
'dayplan.icsTooltip': 'Exportar calendário (ICS)',
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index 1e04bfd..1bbdea4 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -26,6 +26,13 @@ const cs: Record = {
'common.password': 'Heslo',
'common.saving': 'Ukládání...',
'common.saved': 'Uloženo',
+ 'trips.reminder': 'Připomínka',
+ 'trips.reminderNone': 'Žádná',
+ 'trips.reminderDay': 'den',
+ 'trips.reminderDays': 'dní',
+ 'trips.reminderCustom': 'Vlastní',
+ 'trips.reminderDaysBefore': 'dní před odjezdem',
+ 'trips.reminderDisabledHint': 'Připomínky výletů jsou zakázány. Povolte je v Správa > Nastavení > Oznámení.',
'common.update': 'Aktualizovat',
'common.change': 'Změnit',
'common.uploading': 'Nahrávání…',
@@ -246,6 +253,7 @@ const cs: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Události oznámení',
'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
+ 'admin.notifications.configureFirst': 'Nejprve nakonfigurujte nastavení SMTP nebo webhooku níže, poté povolte události.',
'admin.notifications.save': 'Uložit nastavení oznámení',
'admin.notifications.saved': 'Nastavení oznámení uloženo',
'admin.notifications.testWebhook': 'Odeslat testovací webhook',
@@ -254,6 +262,7 @@ const cs: Record = {
'admin.smtp.title': 'E-mail a oznámení',
'admin.smtp.hint': 'Konfigurace SMTP pro odesílání e-mailových oznámení.',
'admin.smtp.testButton': 'Odeslat testovací e-mail',
+ 'admin.webhook.hint': 'Odesílat oznámení na externí webhook (Discord, Slack atd.).',
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 35f44ec..02216a3 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -26,6 +26,13 @@ const de: Record = {
'common.password': 'Passwort',
'common.saving': 'Speichern...',
'common.saved': 'Gespeichert',
+ 'trips.reminder': 'Erinnerung',
+ 'trips.reminderNone': 'Keine',
+ 'trips.reminderDay': 'Tag',
+ 'trips.reminderDays': 'Tage',
+ 'trips.reminderCustom': 'Benutzerdefiniert',
+ 'trips.reminderDaysBefore': 'Tage vor Abreise',
+ 'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.',
'common.update': 'Aktualisieren',
'common.change': 'Ändern',
'common.uploading': 'Hochladen…',
@@ -160,6 +167,7 @@ const de: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Benachrichtigungsereignisse',
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
+ 'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.',
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
'admin.notifications.testWebhook': 'Test-Webhook senden',
@@ -168,6 +176,7 @@ const de: Record = {
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
'admin.smtp.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
'admin.smtp.testButton': 'Test-E-Mail senden',
+ 'admin.webhook.hint': 'Benachrichtigungen an einen externen Webhook senden (Discord, Slack usw.).',
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 3cfbef3..1d58761 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -26,6 +26,13 @@ const en: Record = {
'common.password': 'Password',
'common.saving': 'Saving...',
'common.saved': 'Saved',
+ 'trips.reminder': 'Reminder',
+ 'trips.reminderNone': 'None',
+ 'trips.reminderDay': 'day',
+ 'trips.reminderDays': 'days',
+ 'trips.reminderCustom': 'Custom',
+ 'trips.reminderDaysBefore': 'days before departure',
+ 'trips.reminderDisabledHint': 'Trip reminders are disabled. Enable them in Admin > Settings > Notifications.',
'common.update': 'Update',
'common.change': 'Change',
'common.uploading': 'Uploading…',
@@ -157,6 +164,7 @@ const en: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Notification Events',
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
+ 'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
'admin.notifications.save': 'Save notification settings',
'admin.notifications.saved': 'Notification settings saved',
'admin.notifications.testWebhook': 'Send test webhook',
@@ -165,6 +173,7 @@ const en: Record = {
'admin.smtp.title': 'Email & Notifications',
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
'admin.smtp.testButton': 'Send test email',
+ 'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
'admin.smtp.testSuccess': 'Test email sent successfully',
'admin.smtp.testFailed': 'Test email failed',
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index 8ca4227..4780fe3 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -26,6 +26,13 @@ const es: Record = {
'common.password': 'Contraseña',
'common.saving': 'Guardando...',
'common.saved': 'Guardado',
+ 'trips.reminder': 'Recordatorio',
+ 'trips.reminderNone': 'Ninguno',
+ 'trips.reminderDay': 'día',
+ 'trips.reminderDays': 'días',
+ 'trips.reminderCustom': 'Personalizado',
+ 'trips.reminderDaysBefore': 'días antes de la salida',
+ 'trips.reminderDisabledHint': 'Los recordatorios de viaje están desactivados. Actívalos en Admin > Configuración > Notificaciones.',
'common.update': 'Actualizar',
'common.change': 'Cambiar',
'common.uploading': 'Subiendo…',
@@ -161,6 +168,7 @@ const es: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificación',
'admin.notifications.eventsHint': 'Elige qué eventos activan notificaciones para todos los usuarios.',
+ 'admin.notifications.configureFirst': 'Configura primero los ajustes SMTP o webhook a continuación, luego activa los eventos.',
'admin.notifications.save': 'Guardar configuración de notificaciones',
'admin.notifications.saved': 'Configuración de notificaciones guardada',
'admin.notifications.testWebhook': 'Enviar webhook de prueba',
@@ -169,6 +177,7 @@ const es: Record = {
'admin.smtp.title': 'Correo y notificaciones',
'admin.smtp.hint': 'Configuración SMTP para el envío de notificaciones por correo.',
'admin.smtp.testButton': 'Enviar correo de prueba',
+ 'admin.webhook.hint': 'Enviar notificaciones a un webhook externo (Discord, Slack, etc.).',
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index 09d5a36..19bb83d 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -26,6 +26,13 @@ const fr: Record = {
'common.password': 'Mot de passe',
'common.saving': 'Enregistrement…',
'common.saved': 'Enregistré',
+ 'trips.reminder': 'Rappel',
+ 'trips.reminderNone': 'Aucun',
+ 'trips.reminderDay': 'jour',
+ 'trips.reminderDays': 'jours',
+ 'trips.reminderCustom': 'Personnalisé',
+ 'trips.reminderDaysBefore': 'jours avant le départ',
+ 'trips.reminderDisabledHint': 'Les rappels de voyage sont désactivés. Activez-les dans Admin > Paramètres > Notifications.',
'common.update': 'Mettre à jour',
'common.change': 'Modifier',
'common.uploading': 'Import en cours…',
@@ -160,6 +167,7 @@ const fr: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Événements de notification',
'admin.notifications.eventsHint': 'Choisissez quels événements déclenchent des notifications pour tous les utilisateurs.',
+ 'admin.notifications.configureFirst': 'Configurez d\'abord les paramètres SMTP ou webhook ci-dessous, puis activez les événements.',
'admin.notifications.save': 'Enregistrer les paramètres de notification',
'admin.notifications.saved': 'Paramètres de notification enregistrés',
'admin.notifications.testWebhook': 'Envoyer un webhook de test',
@@ -168,6 +176,7 @@ const fr: Record = {
'admin.smtp.title': 'E-mail et notifications',
'admin.smtp.hint': 'Configuration SMTP pour l\'envoi des notifications par e-mail.',
'admin.smtp.testButton': 'Envoyer un e-mail de test',
+ 'admin.webhook.hint': 'Envoyer des notifications vers un webhook externe (Discord, Slack, etc.).',
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
index ad61c72..83ffbae 100644
--- a/client/src/i18n/translations/hu.ts
+++ b/client/src/i18n/translations/hu.ts
@@ -26,6 +26,13 @@ const hu: Record = {
'common.password': 'Jelszó',
'common.saving': 'Mentés...',
'common.saved': 'Mentve',
+ 'trips.reminder': 'Emlékeztető',
+ 'trips.reminderNone': 'Nincs',
+ 'trips.reminderDay': 'nap',
+ 'trips.reminderDays': 'nap',
+ 'trips.reminderCustom': 'Egyéni',
+ 'trips.reminderDaysBefore': 'nappal indulás előtt',
+ 'trips.reminderDisabledHint': 'Az utazási emlékeztetők ki vannak kapcsolva. Kapcsold be az Admin > Beállítások > Értesítések menüben.',
'common.update': 'Frissítés',
'common.change': 'Módosítás',
'common.uploading': 'Feltöltés…',
@@ -245,6 +252,7 @@ const hu: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Értesítési események',
'admin.notifications.eventsHint': 'Válaszd ki, mely események indítsanak értesítéseket minden felhasználó számára.',
+ 'admin.notifications.configureFirst': 'Először konfiguráld az SMTP vagy webhook beállításokat lent, majd engedélyezd az eseményeket.',
'admin.notifications.save': 'Értesítési beállítások mentése',
'admin.notifications.saved': 'Értesítési beállítások mentve',
'admin.notifications.testWebhook': 'Teszt webhook küldése',
@@ -253,6 +261,7 @@ const hu: Record = {
'admin.smtp.title': 'E-mail és értesítések',
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítések küldéséhez.',
'admin.smtp.testButton': 'Teszt e-mail küldése',
+ 'admin.webhook.hint': 'Értesítések küldése külső webhookra (Discord, Slack stb.).',
'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen',
'dayplan.icsTooltip': 'Naptár exportálása (ICS)',
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 1c3d749..913219a 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -26,6 +26,13 @@ const it: Record = {
'common.password': 'Password',
'common.saving': 'Salvataggio...',
'common.saved': 'Salvato',
+ 'trips.reminder': 'Promemoria',
+ 'trips.reminderNone': 'Nessuno',
+ 'trips.reminderDay': 'giorno',
+ 'trips.reminderDays': 'giorni',
+ 'trips.reminderCustom': 'Personalizzato',
+ 'trips.reminderDaysBefore': 'giorni prima della partenza',
+ 'trips.reminderDisabledHint': 'I promemoria dei viaggi sono disabilitati. Abilitali in Admin > Impostazioni > Notifiche.',
'common.update': 'Aggiorna',
'common.change': 'Cambia',
'common.uploading': 'Caricamento…',
@@ -245,6 +252,7 @@ const it: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventi di notifica',
'admin.notifications.eventsHint': 'Scegli quali eventi attivano le notifiche per tutti gli utenti.',
+ 'admin.notifications.configureFirst': 'Configura prima le impostazioni SMTP o webhook qui sotto, poi abilita gli eventi.',
'admin.notifications.save': 'Salva impostazioni notifiche',
'admin.notifications.saved': 'Impostazioni notifiche salvate',
'admin.notifications.testWebhook': 'Invia webhook di test',
@@ -253,6 +261,7 @@ const it: Record = {
'admin.smtp.title': 'Email e notifiche',
'admin.smtp.hint': 'Configurazione SMTP per l\'invio delle notifiche via e-mail.',
'admin.smtp.testButton': 'Invia email di prova',
+ 'admin.webhook.hint': 'Invia notifiche a un webhook esterno (Discord, Slack, ecc.).',
'admin.smtp.testSuccess': 'Email di prova inviata con successo',
'admin.smtp.testFailed': 'Invio email di prova fallito',
'dayplan.icsTooltip': 'Esporta calendario (ICS)',
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index 610d059..242c841 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -26,6 +26,13 @@ const nl: Record = {
'common.password': 'Wachtwoord',
'common.saving': 'Opslaan...',
'common.saved': 'Opgeslagen',
+ 'trips.reminder': 'Herinnering',
+ 'trips.reminderNone': 'Geen',
+ 'trips.reminderDay': 'dag',
+ 'trips.reminderDays': 'dagen',
+ 'trips.reminderCustom': 'Aangepast',
+ 'trips.reminderDaysBefore': 'dagen voor vertrek',
+ 'trips.reminderDisabledHint': 'Reisherinneringen zijn uitgeschakeld. Schakel ze in via Admin > Instellingen > Meldingen.',
'common.update': 'Bijwerken',
'common.change': 'Wijzigen',
'common.uploading': 'Uploaden…',
@@ -160,6 +167,7 @@ const nl: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Meldingsgebeurtenissen',
'admin.notifications.eventsHint': 'Kies welke gebeurtenissen meldingen activeren voor alle gebruikers.',
+ 'admin.notifications.configureFirst': 'Configureer eerst de SMTP- of webhook-instellingen hieronder en schakel dan de events in.',
'admin.notifications.save': 'Meldingsinstellingen opslaan',
'admin.notifications.saved': 'Meldingsinstellingen opgeslagen',
'admin.notifications.testWebhook': 'Testwebhook verzenden',
@@ -168,6 +176,7 @@ const nl: Record = {
'admin.smtp.title': 'E-mail en meldingen',
'admin.smtp.hint': 'SMTP-configuratie voor het verzenden van e-mailmeldingen.',
'admin.smtp.testButton': 'Test-e-mail verzenden',
+ 'admin.webhook.hint': 'Meldingen verzenden naar een externe webhook (Discord, Slack, enz.).',
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
'admin.smtp.testFailed': 'Test-e-mail mislukt',
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index 4f1a055..293a440 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -26,6 +26,13 @@ const ru: Record = {
'common.password': 'Пароль',
'common.saving': 'Сохранение...',
'common.saved': 'Сохранено',
+ 'trips.reminder': 'Напоминание',
+ 'trips.reminderNone': 'Нет',
+ 'trips.reminderDay': 'день',
+ 'trips.reminderDays': 'дней',
+ 'trips.reminderCustom': 'Другое',
+ 'trips.reminderDaysBefore': 'дней до отъезда',
+ 'trips.reminderDisabledHint': 'Напоминания о поездках отключены. Включите их в Админ > Настройки > Уведомления.',
'common.update': 'Обновить',
'common.change': 'Изменить',
'common.uploading': 'Загрузка…',
@@ -160,6 +167,7 @@ const ru: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'События уведомлений',
'admin.notifications.eventsHint': 'Выберите, какие события вызывают уведомления для всех пользователей.',
+ 'admin.notifications.configureFirst': 'Сначала настройте SMTP или webhook ниже, затем включите события.',
'admin.notifications.save': 'Сохранить настройки уведомлений',
'admin.notifications.saved': 'Настройки уведомлений сохранены',
'admin.notifications.testWebhook': 'Отправить тестовый вебхук',
@@ -168,6 +176,7 @@ const ru: Record = {
'admin.smtp.title': 'Почта и уведомления',
'admin.smtp.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.',
'admin.smtp.testButton': 'Отправить тестовое письмо',
+ 'admin.webhook.hint': 'Отправлять уведомления через внешний webhook (Discord, Slack и т.д.).',
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index b16c82f..be146a7 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -26,6 +26,13 @@ const zh: Record = {
'common.password': '密码',
'common.saving': '保存中...',
'common.saved': '已保存',
+ 'trips.reminder': '提醒',
+ 'trips.reminderNone': '无',
+ 'trips.reminderDay': '天',
+ 'trips.reminderDays': '天',
+ 'trips.reminderCustom': '自定义',
+ 'trips.reminderDaysBefore': '天前提醒',
+ 'trips.reminderDisabledHint': '旅行提醒已禁用。请在管理 > 设置 > 通知中启用。',
'common.update': '更新',
'common.change': '修改',
'common.uploading': '上传中…',
@@ -160,6 +167,7 @@ const zh: Record = {
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': '通知事件',
'admin.notifications.eventsHint': '选择哪些事件为所有用户触发通知。',
+ 'admin.notifications.configureFirst': '请先在下方配置 SMTP 或 Webhook,然后启用事件。',
'admin.notifications.save': '保存通知设置',
'admin.notifications.saved': '通知设置已保存',
'admin.notifications.testWebhook': '发送测试 Webhook',
@@ -168,6 +176,7 @@ const zh: Record = {
'admin.smtp.title': '邮件与通知',
'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。',
'admin.smtp.testButton': '发送测试邮件',
+ 'admin.webhook.hint': '向外部 Webhook 发送通知(Discord、Slack 等)。',
'admin.smtp.testSuccess': '测试邮件发送成功',
'admin.smtp.testFailed': '测试邮件发送失败',
'dayplan.icsTooltip': '导出日历 (ICS)',
diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx
index b0651c7..7489723 100644
--- a/client/src/pages/AdminPage.tsx
+++ b/client/src/pages/AdminPage.tsx
@@ -122,7 +122,7 @@ export default function AdminPage(): React.ReactElement {
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
- const { user: currentUser, updateApiKeys, setAppRequireMfa } = useAuthStore()
+ const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
@@ -999,9 +999,15 @@ export default function AdminPage(): React.ReactElement {
{/* Notification event toggles — shown when any channel is active */}
- {(smtpValues.notification_channel || 'none') !== 'none' && (
-
+ {(smtpValues.notification_channel || 'none') !== 'none' && (() => {
+ const ch = smtpValues.notification_channel || 'none'
+ const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false
+ return (
+
{t('admin.notifications.events')}
+ {!configValid && (
+
{t('admin.notifications.configureFirst')}
+ )}
{t('admin.notifications.eventsHint')}
{[
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
@@ -1029,7 +1035,8 @@ export default function AdminPage(): React.ReactElement {
)
})}
- )}
+ )
+ })()}
{/* Email (SMTP) settings — shown when email channel is active */}
{(smtpValues.notification_channel || 'none') === 'email' && (
@@ -1072,7 +1079,7 @@ export default function AdminPage(): React.ReactElement {
{/* Webhook settings — shown when webhook channel is active */}
{(smtpValues.notification_channel || 'none') === 'webhook' && (
-
{t('admin.webhook.hint') || 'Send notifications to an external webhook (Discord, Slack, etc.).'}
+
{t('admin.webhook.hint')}
{
+ 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"
diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx
index 4dc144a..8ae4046 100644
--- a/client/src/pages/DashboardPage.tsx
+++ b/client/src/pages/DashboardPage.tsx
@@ -145,9 +145,10 @@ interface TripCardProps {
t: (key: string, params?: Record) => string
locale: string
dark?: boolean
+ isAdmin?: boolean
}
-function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
+function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark, isAdmin }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const coverBg = trip.cover_image
@@ -186,12 +187,14 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
{/* Top-right actions */}
+ {(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}>
onEdit(trip)} title={t('common.edit')}>
onArchive(trip.id)} title={t('dashboard.archive')}>
onDelete(trip)} title={t('common.delete')} danger>
+ )}
{/* Bottom content */}
@@ -228,7 +231,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
-function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement {
+function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -305,19 +308,21 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
+ {(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}>
onEdit(trip)} icon={} label={t('common.edit')} />
onArchive(trip.id)} icon={} label={t('dashboard.archive')} />
onDelete(trip)} icon={} label={t('common.delete')} danger />
+ )}
)
}
// ── List View Item ──────────────────────────────────────────────────────────
-function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement {
+function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -403,11 +408,13 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
{/* Actions */}
+ {(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}>
onEdit(trip)} icon={} label="" />
onArchive(trip.id)} icon={} label="" />
onDelete(trip)} icon={} label="" danger />
+ )}
)
}
@@ -421,9 +428,10 @@ interface ArchivedRowProps {
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record) => string
locale: string
+ isAdmin?: boolean
}
-function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
+function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale, isAdmin }: ArchivedRowProps): React.ReactElement {
return (
onClick(trip)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
@@ -449,6 +457,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
)}
+ {(!!trip.is_owner || isAdmin) && (
e.stopPropagation()}>
+ )}
)
}
@@ -539,7 +549,8 @@ export default function DashboardPage(): React.ReactElement {
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
- const { demoMode } = useAuthStore()
+ const { demoMode, user } = useAuthStore()
+ const isAdmin = user?.role === 'admin'
const { settings, updateSetting } = useSettingsStore()
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -781,7 +792,7 @@ export default function DashboardPage(): React.ReactElement {
{!isLoading && spotlight && viewMode === 'grid' && (
{ setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
@@ -797,7 +808,7 @@ export default function DashboardPage(): React.ReactElement {
{ setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
@@ -811,7 +822,7 @@ export default function DashboardPage(): React.ReactElement {
{ setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
@@ -841,7 +852,7 @@ export default function DashboardPage(): React.ReactElement {
{ setEditingTrip(tr); setShowForm(true) }}
onUnarchive={handleUnarchive}
onDelete={handleDelete}
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts
index 66206ee..9fbad53 100644
--- a/client/src/store/authStore.ts
+++ b/client/src/store/authStore.ts
@@ -26,6 +26,7 @@ interface AuthState {
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
+ tripRemindersEnabled: boolean
login: (email: string, password: string) => Promise
completeMfaLogin: (mfaToken: string, code: string) => Promise
@@ -42,6 +43,7 @@ interface AuthState {
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
+ setTripRemindersEnabled: (val: boolean) => void
demoLogin: () => Promise
}
@@ -55,6 +57,7 @@ export const useAuthStore = create((set, get) => ({
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
+ tripRemindersEnabled: false,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
@@ -224,6 +227,7 @@ export const useAuthStore = create((set, get) => ({
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
+ setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
demoLogin: async () => {
set({ isLoading: true, error: null })
diff --git a/client/src/types.ts b/client/src/types.ts
index f4ac7a0..59159f5 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -22,6 +22,7 @@ export interface Trip {
end_date: string
cover_url: string | null
is_archived: boolean
+ reminder_days: number
owner_id: number
created_at: string
updated_at: string
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 3b9661c..cb5c256 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -433,6 +433,9 @@ function runMigrations(db: Database.Database): void {
() => {
try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch {}
},
+ () => {
+ try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch {}
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index 2687b11..0eb24b7 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -41,6 +41,7 @@ function createTables(db: Database.Database): void {
currency TEXT DEFAULT 'EUR',
cover_image TEXT,
is_archived INTEGER DEFAULT 0,
+ reminder_days INTEGER DEFAULT 3,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts
index 80522cd..248d10a 100644
--- a/server/src/db/seeds.ts
+++ b/server/src/db/seeds.ts
@@ -1,11 +1,26 @@
import Database from 'better-sqlite3';
import crypto from 'crypto';
+function isOidcOnlyConfigured(): boolean {
+ if (process.env.OIDC_ONLY !== 'true') return false;
+ return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
+}
+
function seedAdminAccount(db: Database.Database): void {
try {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0) return;
+ if (isOidcOnlyConfigured()) {
+ console.log('');
+ console.log('╔══════════════════════════════════════════════╗');
+ console.log('║ TREK — OIDC-Only Mode ║');
+ console.log('║ First SSO login will become admin. ║');
+ console.log('╚══════════════════════════════════════════════╝');
+ console.log('');
+ return;
+ }
+
const bcrypt = require('bcryptjs');
const password = crypto.randomBytes(12).toString('base64url');
const hash = bcrypt.hashSync(password, 12);
diff --git a/server/src/index.ts b/server/src/index.ts
index 092b232..14f493d 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -112,6 +112,8 @@ app.use(enforceGlobalMfaPolicy);
if (res.statusCode >= 500) {
_logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
+ _logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
} else if (res.statusCode >= 400) {
_logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
}
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index 58694c9..0ae2370 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -7,7 +7,7 @@ import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
-import { writeAudit, getClientIp } from '../services/auditLog';
+import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import { revokeUserSessions } from '../mcp';
const router = express.Router();
@@ -122,8 +122,9 @@ router.put('/users/:id', (req: Request, res: Response) => {
action: 'admin.user_update',
resource: String(req.params.id),
ip: getClientIp(req),
- details: { fields: changed },
+ details: { targetUser: user.email, fields: changed },
});
+ logInfo(`Admin ${authReq.user.email} edited user ${user.email} (fields: ${changed.join(', ')})`);
res.json({ user: updated });
});
@@ -133,8 +134,8 @@ router.delete('/users/:id', (req: Request, res: Response) => {
return res.status(400).json({ error: 'Cannot delete own account' });
}
- const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
- if (!user) return res.status(404).json({ error: 'User not found' });
+ const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(req.params.id) as { id: number; email: string } | undefined;
+ if (!userToDel) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
writeAudit({
@@ -142,7 +143,9 @@ router.delete('/users/:id', (req: Request, res: Response) => {
action: 'admin.user_delete',
resource: String(req.params.id),
ip: getClientIp(req),
+ details: { targetUser: userToDel.email },
});
+ logInfo(`Admin ${authReq.user.email} deleted user ${userToDel.email}`);
res.json({ success: true });
});
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index 34ed423..86e44ad 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -18,6 +18,7 @@ import { revokeUserSessions } from '../mcp';
import { AuthRequest, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
+import { startTripReminders } from '../scheduler';
authenticator.options = { window: 1 };
@@ -185,6 +186,11 @@ router.get('/app-config', (_req: Request, res: Response) => {
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
+ const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
+ const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value);
+ const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value);
+ const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl);
+ const 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());
res.json({
allow_registration: isDemo ? false : allowRegistration,
@@ -202,6 +208,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
+ trip_reminders_enabled: tripRemindersEnabled,
});
});
@@ -684,6 +691,12 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
details: summary,
debugDetails,
});
+
+ const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder'];
+ if (changedKeys.some(k => notifRelated.includes(k))) {
+ startTripReminders();
+ }
+
res.json({ success: true });
});
diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts
index d06dc86..7a61178 100644
--- a/server/src/routes/collab.ts
+++ b/server/src/routes/collab.ts
@@ -129,7 +129,7 @@ router.post('/notes', authenticate, (req: Request, res: Response) => {
import('../services/notifications').then(({ notifyTripMembers }) => {
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.username }).catch(() => {});
+ notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email }).catch(() => {});
});
});
@@ -430,7 +430,7 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
- notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
+ notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview }).catch(() => {});
});
});
diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts
index 006ded8..ef3891b 100644
--- a/server/src/routes/immich.ts
+++ b/server/src/routes/immich.ts
@@ -186,7 +186,7 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
if (shared && added > 0) {
import('../services/notifications').then(({ notifyTripMembers }) => {
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.username, count: String(added) }).catch(() => {});
+ notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {});
});
}
});
diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts
index 962cbd3..0b84d8b 100644
--- a/server/src/routes/packing.ts
+++ b/server/src/routes/packing.ts
@@ -285,7 +285,7 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
for (const uid of user_ids) {
if (uid !== authReq.user.id) {
- notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, category: cat } }).catch(() => {});
+ notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat } }).catch(() => {});
}
}
});
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index 79ae03a..5bd4625 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -105,7 +105,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
// Notify trip members about new booking
import('../services/notifications').then(({ notifyTripMembers }) => {
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.username, booking: title, type: type || 'booking' }).catch(() => {});
+ notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking' }).catch(() => {});
});
});
@@ -225,7 +225,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
import('../services/notifications').then(({ notifyTripMembers }) => {
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.username, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {});
+ notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {});
});
});
@@ -250,7 +250,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
import('../services/notifications').then(({ notifyTripMembers }) => {
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.username, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
+ notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking' }).catch(() => {});
});
});
diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts
index 5933ac8..7145fc7 100644
--- a/server/src/routes/trips.ts
+++ b/server/src/routes/trips.ts
@@ -7,7 +7,7 @@ import { db, canAccessTrip, isOwner } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Trip, User } from '../types';
-import { writeAudit, getClientIp } from '../services/auditLog';
+import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
const router = express.Router();
@@ -125,30 +125,42 @@ router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const archived = req.query.archived === '1' ? 1 : 0;
const userId = authReq.user.id;
- const trips = db.prepare(`
- ${TRIP_SELECT}
- LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
- WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
- ORDER BY t.created_at DESC
- `).all({ userId, archived });
+ const isAdminUser = authReq.user.role === 'admin';
+ const trips = isAdminUser
+ ? db.prepare(`
+ ${TRIP_SELECT}
+ WHERE t.is_archived = :archived
+ ORDER BY t.created_at DESC
+ `).all({ userId, archived })
+ : db.prepare(`
+ ${TRIP_SELECT}
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
+ WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
+ ORDER BY t.created_at DESC
+ `).all({ userId, archived });
res.json({ trips });
});
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- const { title, description, start_date, end_date, currency } = req.body;
+ const { title, description, start_date, end_date, currency, reminder_days } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'End date must be after start date' });
+ const rd = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : 3) : 3;
+
const result = db.prepare(`
- INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
- VALUES (?, ?, ?, ?, ?, ?)
- `).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR');
+ INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR', rd);
const tripId = result.lastInsertRowid;
generateDays(tripId, start_date, end_date);
- writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title } });
+ writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title, reminder_days: rd === 0 ? 'none' : `${rd} days` } });
+ if (rd > 0) {
+ logInfo(`${authReq.user.email} set ${rd}-day reminder for trip "${title}"`);
+ }
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
res.status(201).json({ trip });
});
@@ -156,27 +168,26 @@ router.post('/', authenticate, (req: Request, res: Response) => {
router.get('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const userId = authReq.user.id;
- const trip = db.prepare(`
- ${TRIP_SELECT}
- LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
- WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
- `).get({ userId, tripId: req.params.id });
+ const isAdminUser = authReq.user.role === 'admin';
+ const trip = isAdminUser
+ ? db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: req.params.id })
+ : db.prepare(`
+ ${TRIP_SELECT}
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
+ WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
+ `).get({ userId, tripId: req.params.id });
if (!trip) return res.status(404).json({ error: 'Trip not found' });
res.json({ trip });
});
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- const access = canAccessTrip(req.params.id, authReq.user.id);
- if (!access) return res.status(404).json({ error: 'Trip not found' });
-
- const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
- if (ownerOnly && !isOwner(req.params.id, authReq.user.id))
- return res.status(403).json({ error: 'Only the owner can change this setting' });
+ if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
+ return res.status(403).json({ error: 'Only the trip owner can edit trip details' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
- const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
+ const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = req.body;
if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'End date must be after start date' });
@@ -188,16 +199,41 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const newCurrency = currency || trip.currency;
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
+ const newReminder = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : (trip as any).reminder_days) : (trip as any).reminder_days;
db.prepare(`
UPDATE trips SET title=?, description=?, start_date=?, end_date=?,
- currency=?, is_archived=?, cover_image=?, updated_at=CURRENT_TIMESTAMP
+ currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
- `).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, req.params.id);
+ `).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, req.params.id);
if (newStart !== trip.start_date || newEnd !== trip.end_date)
generateDays(req.params.id, newStart, newEnd);
+ const changes: Record = {};
+ if (title && title !== trip.title) changes.title = title;
+ if (newStart !== trip.start_date) changes.start_date = newStart;
+ if (newEnd !== trip.end_date) changes.end_date = newEnd;
+ if (newReminder !== (trip as any).reminder_days) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`;
+ if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived;
+
+ const isAdminEdit = authReq.user.role === 'admin' && trip.user_id !== authReq.user.id;
+ if (Object.keys(changes).length > 0) {
+ const ownerEmail = isAdminEdit ? (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email : undefined;
+ writeAudit({ userId: authReq.user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(req.params.id), trip: newTitle, ...(ownerEmail ? { owner: ownerEmail } : {}), ...changes } });
+ if (isAdminEdit && ownerEmail) {
+ logInfo(`Admin ${authReq.user.email} edited trip "${newTitle}" owned by ${ownerEmail}`);
+ }
+ }
+
+ if (newReminder !== (trip as any).reminder_days) {
+ if (newReminder > 0) {
+ logInfo(`${authReq.user.email} set ${newReminder}-day reminder for trip "${newTitle}"`);
+ } else {
+ logInfo(`${authReq.user.email} removed reminder for trip "${newTitle}"`);
+ }
+ }
+
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: req.params.id });
res.json({ trip: updatedTrip });
broadcast(req.params.id, 'trip:updated', { trip: updatedTrip }, req.headers['x-socket-id'] as string);
@@ -228,10 +264,16 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- if (!isOwner(req.params.id, authReq.user.id))
+ if (!isOwner(req.params.id, authReq.user.id) && authReq.user.role !== 'admin')
return res.status(403).json({ error: 'Only the owner can delete the trip' });
const deletedTripId = Number(req.params.id);
- writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId } });
+ const delTrip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(req.params.id) as { title: string; user_id: number } | undefined;
+ const isAdminDel = authReq.user.role === 'admin' && delTrip && delTrip.user_id !== authReq.user.id;
+ const ownerEmail = isAdminDel ? (db.prepare('SELECT email FROM users WHERE id = ?').get(delTrip!.user_id) as { email: string } | undefined)?.email : undefined;
+ writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId, trip: delTrip?.title, ...(ownerEmail ? { owner: ownerEmail } : {}) } });
+ if (isAdminDel && ownerEmail) {
+ logInfo(`Admin ${authReq.user.email} deleted trip "${delTrip!.title}" owned by ${ownerEmail}`);
+ }
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
res.json({ success: true });
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id'] as string);
@@ -290,7 +332,7 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
// Notify invited user
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
import('../services/notifications').then(({ notify }) => {
- notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, invitee: target.username } }).catch(() => {});
+ notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, invitee: target.email } }).catch(() => {});
});
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts
index ee03496..90374a3 100644
--- a/server/src/routes/vacay.ts
+++ b/server/src/routes/vacay.ts
@@ -351,7 +351,7 @@ router.post('/invite', (req: Request, res: Response) => {
// Notify invited user
import('../services/notifications').then(({ notify }) => {
- notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {});
+ notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.email } }).catch(() => {});
});
res.json({ success: true });
diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts
index 12c2c97..6599e52 100644
--- a/server/src/scheduler.ts
+++ b/server/src/scheduler.ts
@@ -160,42 +160,55 @@ let reminderTask: ScheduledTask | null = null;
function startTripReminders(): void {
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
+ try {
+ const { db } = require('./db/database');
+ const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
+ const channel = getSetting('notification_channel') || 'none';
+ const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
+ const hasSmtp = !!(getSetting('smtp_host') || '').trim();
+ const hasWebhook = !!(getSetting('notification_webhook_url') || '').trim();
+ const channelReady = (channel === 'email' && hasSmtp) || (channel === 'webhook' && hasWebhook);
+
+ if (!channelReady || !reminderEnabled) {
+ const { logInfo: li } = require('./services/auditLog');
+ const reason = !channelReady ? `no ${channel === 'none' ? 'notification channel' : channel} configuration` : 'trip reminders disabled in settings';
+ li(`Trip reminders: disabled (${reason})`);
+ return;
+ }
+
+ const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
+ const { logInfo: liSetup } = require('./services/auditLog');
+ liSetup(`Trip reminders: enabled via ${channel}${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
+ } catch {
+ return;
+ }
+
const tz = process.env.TZ || 'UTC';
reminderTask = cron.schedule('0 9 * * *', async () => {
try {
const { db } = require('./db/database');
- const { notify } = require('./services/notifications');
-
- const tomorrow = new Date();
- tomorrow.setDate(tomorrow.getDate() + 1);
- const dateStr = tomorrow.toISOString().split('T')[0];
+ const { notifyTripMembers } = require('./services/notifications');
const trips = db.prepare(`
- SELECT t.id, t.title, t.user_id FROM trips t
- WHERE t.start_date = ?
- `).all(dateStr) as { id: number; title: string; user_id: number }[];
+ SELECT t.id, t.title, t.user_id, t.reminder_days FROM trips t
+ WHERE t.reminder_days > 0
+ AND t.start_date IS NOT NULL
+ AND t.start_date = date('now', '+' || t.reminder_days || ' days')
+ `).all() as { id: number; title: string; user_id: number; reminder_days: number }[];
for (const trip of trips) {
- await notify({ userId: trip.user_id, event: 'trip_reminder', params: { trip: trip.title } }).catch(() => {});
-
- const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[];
- for (const m of members) {
- await notify({ userId: m.user_id, event: 'trip_reminder', params: { trip: trip.title } }).catch(() => {});
- }
+ await notifyTripMembers(trip.id, 0, 'trip_reminder', { trip: trip.title }).catch(() => {});
}
+ const { logInfo: li } = require('./services/auditLog');
if (trips.length > 0) {
- const { logInfo: li } = require('./services/auditLog');
- li(`Trip reminders sent for ${trips.length} trip(s) starting ${dateStr}`);
+ li(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
-
- const { logInfo: li4 } = require('./services/auditLog');
- li4(`Trip reminders scheduled: daily at 09:00 (${tz})`);
}
function stop(): void {