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 {