diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 7a2668a..569040a 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -289,4 +289,10 @@ export const backupApi = { setAutoSettings: (settings: Record) => apiClient.put('/backup/auto-settings', settings).then(r => r.data), } +export const notificationsApi = { + getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), + updatePreferences: (prefs: Record) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), + testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data), +} + export default apiClient diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 32fdd2b..320484e 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -714,6 +714,34 @@ export default function DayPlanSidebar({ {t('dayplan.pdf')} + diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index b3af280..777971d 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -140,6 +140,21 @@ const de: Record = { 'settings.timeFormat': 'Zeitformat', 'settings.routeCalculation': 'Routenberechnung', 'settings.blurBookingCodes': 'Buchungscodes verbergen', + 'settings.notifications': 'Benachrichtigungen', + 'settings.notifyTripInvite': 'Trip-Einladungen', + 'settings.notifyBookingChange': 'Buchungsänderungen', + 'settings.notifyTripReminder': 'Trip-Erinnerungen', + 'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen', + 'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)', + 'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)', + 'settings.notifyPackingTagged': 'Packliste: Zuweisungen', + 'settings.notifyWebhook': 'Webhook-Benachrichtigungen', + 'admin.smtp.title': 'E-Mail & Benachrichtigungen', + 'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.', + 'admin.smtp.testButton': 'Test-E-Mail senden', + 'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet', + 'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen', + 'dayplan.icsTooltip': 'Kalender exportieren (ICS)', 'settings.on': 'An', 'settings.off': 'Aus', 'settings.account': 'Konto', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index acb0a5c..da14e70 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -140,6 +140,21 @@ const en: Record = { 'settings.timeFormat': 'Time Format', 'settings.routeCalculation': 'Route Calculation', 'settings.blurBookingCodes': 'Blur Booking Codes', + 'settings.notifications': 'Notifications', + 'settings.notifyTripInvite': 'Trip invitations', + 'settings.notifyBookingChange': 'Booking changes', + 'settings.notifyTripReminder': 'Trip reminders', + 'settings.notifyVacayInvite': 'Vacay fusion invitations', + 'settings.notifyPhotosShared': 'Shared photos (Immich)', + 'settings.notifyCollabMessage': 'Chat messages (Collab)', + 'settings.notifyPackingTagged': 'Packing list: assignments', + 'settings.notifyWebhook': 'Webhook notifications', + 'admin.smtp.title': 'Email & Notifications', + 'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.', + 'admin.smtp.testButton': 'Send test email', + 'admin.smtp.testSuccess': 'Test email sent successfully', + 'admin.smtp.testFailed': 'Test email failed', + 'dayplan.icsTooltip': 'Export calendar (ICS)', 'settings.on': 'On', 'settings.off': 'Off', 'settings.account': 'Account', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 8266ea6..06c944d 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { adminApi, authApi } from '../api/client' +import apiClient, { adminApi, authApi, notificationsApi } from '../api/client' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { useTranslation } from '../i18n' @@ -93,6 +93,16 @@ export default function AdminPage(): React.ReactElement { const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv') const [savingFileTypes, setSavingFileTypes] = useState(false) + // SMTP settings + const [smtpValues, setSmtpValues] = useState>({}) + const [smtpLoaded, setSmtpLoaded] = useState(false) + useEffect(() => { + apiClient.get('/auth/app-settings').then(r => { + setSmtpValues(r.data || {}) + setSmtpLoaded(true) + }).catch(() => setSmtpLoaded(true)) + }, []) + // API Keys const [mapsKey, setMapsKey] = useState('') const [weatherKey, setWeatherKey] = useState('') @@ -918,6 +928,51 @@ export default function AdminPage(): React.ReactElement { + {/* SMTP / Notifications */} +
+
+

{t('admin.smtp.title')}

+

{t('admin.smtp.hint')}

+
+
+ {smtpLoaded && [ + { key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' }, + { key: 'smtp_port', label: 'SMTP Port', placeholder: '587' }, + { key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' }, + { key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' }, + { key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' }, + { key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' }, + { key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' }, + ].map(field => ( +
+ + setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))} + placeholder={field.placeholder} + onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+ ))} + +
+
)} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 49b5c8a..bc469b1 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -7,7 +7,7 @@ import Navbar from '../components/Layout/Navbar' import CustomSelect from '../components/shared/CustomSelect' import { useToast } from '../components/shared/Toast' import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react' -import { authApi, adminApi } from '../api/client' +import { authApi, adminApi, notificationsApi } from '../api/client' import apiClient from '../api/client' import type { LucideIcon } from 'lucide-react' import type { UserWithOidc } from '../types' @@ -46,6 +46,60 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem ) } +function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) { + const [prefs, setPrefs] = useState | null>(null) + const [addons, setAddons] = useState>({}) + useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, []) + useEffect(() => { + apiClient.get('/addons').then(r => { + const map: Record = {} + for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled + setAddons(map) + }).catch(() => {}) + }, []) + + const toggle = async (key: string) => { + if (!prefs) return + const newVal = prefs[key] ? 0 : 1 + setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev) + try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {} + } + + if (!prefs) return

{t('common.loading')}

+ + const options = [ + { key: 'notify_trip_invite', label: t('settings.notifyTripInvite') }, + { key: 'notify_booking_change', label: t('settings.notifyBookingChange') }, + ...(addons.vacay !== false ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []), + ...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []), + ...(addons.collab !== false ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []), + ...(addons.documents !== false ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []), + { key: 'notify_webhook', label: t('settings.notifyWebhook') }, + ] + + return ( +
+ {options.map(opt => ( +
+ {opt.label} + +
+ ))} +
+ ) +} + export default function SettingsPage(): React.ReactElement { const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore() const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) @@ -474,6 +528,11 @@ export default function SettingsPage(): React.ReactElement { + {/* Notifications */} +
+ +
+ {/* Immich — only when Memories addon is enabled */} {memoriesEnabled && (
diff --git a/server/package-lock.json b/server/package-lock.json index dd28711..77f5176 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-server", - "version": "2.6.2", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-server", - "version": "2.6.2", + "version": "2.7.0", "dependencies": { "archiver": "^6.0.1", "bcryptjs": "^2.4.3", @@ -19,6 +19,7 @@ "multer": "^2.1.1", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "nodemailer": "^8.0.4", "otplib": "^12.0.1", "qrcode": "^1.5.4", "tsx": "^4.21.0", @@ -37,6 +38,7 @@ "@types/multer": "^2.1.0", "@types/node": "^25.5.0", "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^7.0.11", "@types/qrcode": "^1.5.5", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", @@ -653,6 +655,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -2520,6 +2532,15 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/server/package.json b/server/package.json index 103b706..9f50e4f 100644 --- a/server/package.json +++ b/server/package.json @@ -17,9 +17,10 @@ "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "node-cron": "^4.2.1", + "node-fetch": "^2.7.0", + "nodemailer": "^8.0.4", "otplib": "^12.0.1", "qrcode": "^1.5.4", - "node-fetch": "^2.7.0", "tsx": "^4.21.0", "typescript": "^6.0.2", "unzipper": "^0.12.3", @@ -36,6 +37,7 @@ "@types/multer": "^2.1.0", "@types/node": "^25.5.0", "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^7.0.11", "@types/qrcode": "^1.5.5", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 015cf69..1b79064 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -333,6 +333,29 @@ function runMigrations(db: Database.Database): void { // Add target_date to bucket_list for optional visit planning try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {} }, + () => { + // Notification preferences per user + db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notify_trip_invite INTEGER DEFAULT 1, + notify_booking_change INTEGER DEFAULT 1, + notify_trip_reminder INTEGER DEFAULT 1, + notify_vacay_invite INTEGER DEFAULT 1, + notify_photos_shared INTEGER DEFAULT 1, + notify_collab_message INTEGER DEFAULT 1, + notify_packing_tagged INTEGER DEFAULT 1, + notify_webhook INTEGER DEFAULT 0, + UNIQUE(user_id) + )`); + }, + () => { + // Add missing notification preference columns for existing tables + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch {} + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch {} + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {} + try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/index.ts b/server/src/index.ts index 770f0c9..ab39bef 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -160,6 +160,9 @@ app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/backup', backupRoutes); +import notificationRoutes from './routes/notifications'; +app.use('/api/notifications', notificationRoutes); + // Serve static files in production if (process.env.NODE_ENV === 'production') { const publicPath = path.join(__dirname, '../public'); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index de58aeb..23807c8 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -515,17 +515,33 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) = res.json(result); }); +const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'notification_webhook_url', 'app_url']; + +router.get('/app-settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined; + if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); + + const result: Record = {}; + for (const key of ADMIN_SETTINGS_KEYS) { + const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined; + if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value; + } + res.json(result); +}); + router.put('/app-settings', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined; if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); - const { allow_registration, allowed_file_types } = req.body; - if (allow_registration !== undefined) { - db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration)); - } - if (allowed_file_types !== undefined) { - db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types)); + for (const key of ADMIN_SETTINGS_KEYS) { + if (req.body[key] !== undefined) { + const val = String(req.body[key]); + // Don't save masked password + if (key === 'smtp_pass' && val === '••••••••') continue; + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); + } } res.json({ success: true }); }); diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts index 3c5d6b2..6424024 100644 --- a/server/src/routes/collab.ts +++ b/server/src/routes/collab.ts @@ -419,6 +419,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r const formatted = formatMessage(message); res.status(201).json({ message: formatted }); broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string); + + // Notify trip members about new chat message + 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(() => {}); + }); }); router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => { diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 83b480d..e5dca07 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -155,6 +155,14 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) res.json({ success: true, added }); broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + + // Notify trip members about shared photos + 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(() => {}); + }); + } }); // Remove a photo from a trip (own photos only) diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts new file mode 100644 index 0000000..19b6d70 --- /dev/null +++ b/server/src/routes/notifications.ts @@ -0,0 +1,58 @@ +import express, { Request, Response } from 'express'; +import { db } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { AuthRequest } from '../types'; +import { testSmtp } from '../services/notifications'; + +const router = express.Router(); + +// Get user's notification preferences +router.get('/preferences', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id); + if (!prefs) { + db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id); + prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id); + } + res.json({ preferences: prefs }); +}); + +// Update user's notification preferences +router.put('/preferences', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body; + + // Ensure row exists + const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id); + if (!existing) { + db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id); + } + + db.prepare(`UPDATE notification_preferences SET + notify_trip_invite = COALESCE(?, notify_trip_invite), + notify_booking_change = COALESCE(?, notify_booking_change), + notify_trip_reminder = COALESCE(?, notify_trip_reminder), + notify_webhook = COALESCE(?, notify_webhook) + WHERE user_id = ?`).run( + notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null, + notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null, + notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null, + notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null, + authReq.user.id + ); + + const prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id); + res.json({ preferences: prefs }); +}); + +// Admin: test SMTP configuration +router.post('/test-smtp', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); + + const { email } = req.body; + const result = await testSmtp(email || authReq.user.email); + res.json(result); +}); + +export default router; diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts index f0877c0..962cbd3 100644 --- a/server/src/routes/packing.ts +++ b/server/src/routes/packing.ts @@ -278,6 +278,18 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res res.json({ assignees: rows }); broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string); + + // Notify newly assigned users + if (Array.isArray(user_ids) && user_ids.length > 0) { + import('../services/notifications').then(({ notify }) => { + 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(() => {}); + } + } + }); + } }); router.put('/reorder', authenticate, (req: Request, res: Response) => { diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts index 1961fc9..0c8b09c 100644 --- a/server/src/routes/reservations.ts +++ b/server/src/routes/reservations.ts @@ -101,6 +101,12 @@ router.post('/', authenticate, (req: Request, res: Response) => { res.status(201).json({ reservation }); broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string); + + // 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(() => {}); + }); }); // Batch update day_plan_position for multiple reservations (must be before /:id) diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index ad5231d..555edf4 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -284,6 +284,12 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => { db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id); + // 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 } }).catch(() => {}); + }); + res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } }); }); @@ -301,4 +307,69 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response res.json({ success: true }); }); +// ICS calendar export +router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!canAccessTrip(req.params.id, authReq.user.id)) + return res.status(404).json({ error: 'Trip not found' }); + + const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any; + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[]; + const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[]; + + const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m); + const fmtDate = (d: string) => d.replace(/-/g, ''); + const fmtDateTime = (d: string) => d.replace(/[-:]/g, '').replace('T', 'T') + (d.includes('T') ? '00' : ''); + const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; + + let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; + ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`; + + // Trip as all-day event + if (trip.start_date && trip.end_date) { + const endNext = new Date(trip.end_date + 'T00:00:00'); + endNext.setDate(endNext.getDate() + 1); + const endStr = endNext.toISOString().split('T')[0].replace(/-/g, ''); + ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; + if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + // Reservations as events + for (const r of reservations) { + if (!r.reservation_time) continue; + const hasTime = r.reservation_time.includes('T'); + const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; + + ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\n`; + if (hasTime) { + ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`; + if (r.reservation_end_time) ics += `DTEND:${fmtDateTime(r.reservation_end_time)}\r\n`; + } else { + ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`; + } + ics += `SUMMARY:${esc(r.title)}\r\n`; + + let desc = r.type ? `Type: ${r.type}` : ''; + if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`; + if (meta.airline) desc += `\\nAirline: ${meta.airline}`; + if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`; + if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`; + if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`; + if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`; + if (r.notes) desc += `\\n${r.notes}`; + if (desc) ics += `DESCRIPTION:${desc}\r\n`; + if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + ics += 'END:VCALENDAR\r\n'; + + res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`); + res.send(ics); +}); + export default router; diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts index 0dd6ba9..ee03496 100644 --- a/server/src/routes/vacay.ts +++ b/server/src/routes/vacay.ts @@ -349,6 +349,11 @@ router.post('/invite', (req: Request, res: Response) => { }); } catch { /* websocket not available */ } + // Notify invited user + import('../services/notifications').then(({ notify }) => { + notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {}); + }); + res.json({ success: true }); }); diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts new file mode 100644 index 0000000..3cbb02d --- /dev/null +++ b/server/src/services/notifications.ts @@ -0,0 +1,297 @@ +import nodemailer from 'nodemailer'; +import fetch from 'node-fetch'; +import { db } from '../db/database'; + +// ── Types ────────────────────────────────────────────────────────────────── + +type EventType = 'trip_invite' | 'booking_change' | 'trip_reminder' | 'vacay_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged'; + +interface NotificationPayload { + userId: number; + event: EventType; + params: Record; +} + +interface SmtpConfig { + host: string; + port: number; + user: string; + pass: string; + from: string; + secure: boolean; +} + +// ── Settings helpers ─────────────────────────────────────────────────────── + +function getAppSetting(key: string): string | null { + return (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; +} + +function getSmtpConfig(): SmtpConfig | null { + const host = process.env.SMTP_HOST || getAppSetting('smtp_host'); + const port = process.env.SMTP_PORT || getAppSetting('smtp_port'); + const user = process.env.SMTP_USER || getAppSetting('smtp_user'); + const pass = process.env.SMTP_PASS || getAppSetting('smtp_pass'); + const from = process.env.SMTP_FROM || getAppSetting('smtp_from'); + if (!host || !port || !from) return null; + return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 }; +} + +function getWebhookUrl(): string | null { + return process.env.NOTIFICATION_WEBHOOK_URL || getAppSetting('notification_webhook_url'); +} + +function getAppUrl(): string { + return process.env.APP_URL || getAppSetting('app_url') || ''; +} + +function getUserEmail(userId: number): string | null { + return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null; +} + +function getUserLanguage(userId: number): string { + return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en'; +} + +function getUserPrefs(userId: number): Record { + const row = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as any; + return row || { notify_trip_invite: 1, notify_booking_change: 1, notify_trip_reminder: 1, notify_vacay_invite: 1, notify_photos_shared: 1, notify_collab_message: 1, notify_packing_tagged: 1, notify_webhook: 0 }; +} + +// Event → preference column mapping +const EVENT_PREF_MAP: Record = { + trip_invite: 'notify_trip_invite', + booking_change: 'notify_booking_change', + trip_reminder: 'notify_trip_reminder', + vacay_invite: 'notify_vacay_invite', + photos_shared: 'notify_photos_shared', + collab_message: 'notify_collab_message', + packing_tagged: 'notify_packing_tagged', +}; + +// ── Email i18n strings ───────────────────────────────────────────────────── + +interface EmailStrings { footer: string; manage: string; madeWith: string; openTrek: string } + +const I18N: Record = { + en: { footer: 'You received this because you have notifications enabled in TREK.', manage: 'Manage preferences in Settings', madeWith: 'Made with', openTrek: 'Open TREK' }, + de: { footer: 'Du erhältst diese E-Mail, weil du Benachrichtigungen in TREK aktiviert hast.', manage: 'Einstellungen verwalten', madeWith: 'Made with', openTrek: 'TREK öffnen' }, + fr: { footer: 'Vous recevez cet e-mail car les notifications sont activées dans TREK.', manage: 'Gérer les préférences', madeWith: 'Made with', openTrek: 'Ouvrir TREK' }, + es: { footer: 'Recibiste esto porque tienes las notificaciones activadas en TREK.', manage: 'Gestionar preferencias', madeWith: 'Made with', openTrek: 'Abrir TREK' }, + nl: { footer: 'Je ontvangt dit omdat je meldingen hebt ingeschakeld in TREK.', manage: 'Voorkeuren beheren', madeWith: 'Made with', openTrek: 'TREK openen' }, + ru: { footer: 'Вы получили это, потому что у вас включены уведомления в TREK.', manage: 'Управление настройками', madeWith: 'Made with', openTrek: 'Открыть TREK' }, + zh: { footer: '您收到此邮件是因为您在 TREK 中启用了通知。', manage: '管理偏好设置', madeWith: 'Made with', openTrek: '打开 TREK' }, + ar: { footer: 'تلقيت هذا لأنك قمت بتفعيل الإشعارات في TREK.', manage: 'إدارة التفضيلات', madeWith: 'Made with', openTrek: 'فتح TREK' }, +}; + +// Translated notification texts per event type +interface EventText { title: string; body: string } +type EventTextFn = (params: Record) => EventText + +const EVENT_TEXTS: Record> = { + en: { + trip_invite: p => ({ title: `You've been invited to "${p.trip}"`, body: `${p.actor} invited you to the trip "${p.trip}". Open TREK to view and start planning!` }), + booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }), + trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }), + vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }), + photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }), + collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), + packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }), + }, + de: { + trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat dich zur Reise "${p.trip}" eingeladen. Öffne TREK um die Planung zu starten!` }), + booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }), + trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }), + vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }), + photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }), + collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), + packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }), + }, + fr: { + trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} vous a invité au voyage "${p.trip}". Ouvrez TREK pour commencer la planification !` }), + booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }), + trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }), + vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }), + photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }), + collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }), + packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }), + }, + es: { + trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} te invitó al viaje "${p.trip}". ¡Abre TREK para comenzar a planificar!` }), + booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }), + trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }), + vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }), + photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }), + collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), + packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }), + }, + nl: { + trip_invite: p => ({ title: `Uitgenodigd voor "${p.trip}"`, body: `${p.actor} heeft je uitgenodigd voor de reis "${p.trip}". Open TREK om te beginnen met plannen!` }), + booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }), + trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }), + vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }), + photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }), + collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), + packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }), + }, + ru: { + trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил вас в поездку "${p.trip}". Откройте TREK чтобы начать планирование!` }), + booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }), + trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }), + vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }), + photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }), + collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), + packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }), + }, + zh: { + trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请你加入旅行"${p.trip}"。打开 TREK 开始规划!` }), + booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"(${p.type})。` }), + trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }), + vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }), + photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }), + collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }), + packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }), + }, + ar: { + trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعاك إلى الرحلة "${p.trip}". افتح TREK لبدء التخطيط!` }), + booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }), + trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }), + vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }), + photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }), + collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), + packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }), + }, +}; + +// Get localized event text +function getEventText(lang: string, event: EventType, params: Record): EventText { + const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en; + return texts[event](params); +} + +// ── Email HTML builder ───────────────────────────────────────────────────── + +function buildEmailHtml(subject: string, body: string, lang: string): string { + const s = I18N[lang] || I18N.en; + const appUrl = getAppUrl(); + const ctaHref = appUrl || '#'; + + return ` + + + + + +
+ + + + + + + ${appUrl ? `` : ''} + + +
+ TREK +
TREK
+
Travel Resource & Exploration Kit
+
+

${subject}

+
+

${body}

+
+ ${s.openTrek} +
+

${s.footer}
${s.manage}

+

${s.madeWith} by Maurice · GitHub

+
+
+ +`; +} + +// ── Send functions ───────────────────────────────────────────────────────── + +async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise { + const config = getSmtpConfig(); + if (!config) return false; + + const lang = userId ? getUserLanguage(userId) : 'en'; + + try { + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.user ? { user: config.user, pass: config.pass } : undefined, + }); + + await transporter.sendMail({ + from: config.from, + to, + subject: `TREK — ${subject}`, + text: body, + html: buildEmailHtml(subject, body, lang), + }); + return true; + } catch (err) { + console.error('[Notifications] Email send failed:', err instanceof Error ? err.message : err); + return false; + } +} + +async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise { + const url = getWebhookUrl(); + if (!url) return false; + + try { + await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }), + signal: AbortSignal.timeout(10000), + }); + return true; + } catch (err) { + console.error('[Notifications] Webhook failed:', err instanceof Error ? err.message : err); + return false; + } +} + +// ── Public API ───────────────────────────────────────────────────────────── + +export async function notify(payload: NotificationPayload): Promise { + const prefs = getUserPrefs(payload.userId); + const prefKey = EVENT_PREF_MAP[payload.event]; + if (prefKey && !prefs[prefKey]) return; + + const lang = getUserLanguage(payload.userId); + const { title, body } = getEventText(lang, payload.event, payload.params); + + const email = getUserEmail(payload.userId); + if (email) await sendEmail(email, title, body, payload.userId); + if (prefs.notify_webhook) await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip }); +} + +export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record): Promise { + const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; + if (!trip) return; + + const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[]; + const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId); + const unique = [...new Set(allIds)]; + + for (const userId of unique) { + await notify({ userId, event, params }); + } +} + +export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> { + try { + const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.'); + return sent ? { success: true } : { success: false, error: 'SMTP not configured' }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } +}