feat: email notifications, webhook support, ICS export — closes #110
Email Notifications: - SMTP configuration in Admin > Settings (host, port, user, pass, from) - App URL setting for email CTA links - Webhook URL support (Discord, Slack, custom) - Test email button with SMTP validation - Beautiful HTML email template with TREK logo, slogan, red heart footer - All notification texts translated in 8 languages (en/de/fr/es/nl/ru/zh/ar) - Emails sent in each user's language preference Notification Events: - Trip invitation (member added) - Booking created (new reservation) - Vacay fusion invite - Photos shared (Immich) - Collab chat message - Packing list category assignment User Notification Preferences: - Per-user toggle for each event type in Settings - Addon-aware: Vacay/Collab/Photos toggles hidden when addon disabled - Webhook opt-in per user ICS Calendar Export: - Download button next to PDF in day plan header - Exports trip dates + all reservations with details - Compatible with Google Calendar, Apple Calendar, Outlook Technical: - Nodemailer for SMTP - notification_preferences DB table with per-event columns - GET/PUT /auth/app-settings for admin config persistence - POST /notifications/test-smtp for validation - Dynamic imports for non-blocking notification sends
This commit is contained in:
@@ -289,4 +289,10 @@ export const backupApi = {
|
|||||||
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
setAutoSettings: (settings: Record<string, unknown>) => 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<string, boolean>) => 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
|
export default apiClient
|
||||||
|
|||||||
@@ -714,6 +714,34 @@ export default function DayPlanSidebar({
|
|||||||
<FileDown size={13} strokeWidth={2} />
|
<FileDown size={13} strokeWidth={2} />
|
||||||
{t('dayplan.pdf')}
|
{t('dayplan.pdf')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${trip?.title || 'trip'}.ics`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch { toast.error('ICS export failed') }
|
||||||
|
}}
|
||||||
|
title={t('dayplan.icsTooltip')}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '5px 10px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'none',
|
||||||
|
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileDown size={13} strokeWidth={2} />
|
||||||
|
ICS
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,21 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.timeFormat': 'Zeitformat',
|
'settings.timeFormat': 'Zeitformat',
|
||||||
'settings.routeCalculation': 'Routenberechnung',
|
'settings.routeCalculation': 'Routenberechnung',
|
||||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
'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.on': 'An',
|
||||||
'settings.off': 'Aus',
|
'settings.off': 'Aus',
|
||||||
'settings.account': 'Konto',
|
'settings.account': 'Konto',
|
||||||
|
|||||||
@@ -140,6 +140,21 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.timeFormat': 'Time Format',
|
'settings.timeFormat': 'Time Format',
|
||||||
'settings.routeCalculation': 'Route Calculation',
|
'settings.routeCalculation': 'Route Calculation',
|
||||||
'settings.blurBookingCodes': 'Blur Booking Codes',
|
'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.on': 'On',
|
||||||
'settings.off': 'Off',
|
'settings.off': 'Off',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
@@ -93,6 +93,16 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
||||||
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
|
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
|
||||||
|
|
||||||
|
// SMTP settings
|
||||||
|
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
|
||||||
|
const [smtpLoaded, setSmtpLoaded] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/auth/app-settings').then(r => {
|
||||||
|
setSmtpValues(r.data || {})
|
||||||
|
setSmtpLoaded(true)
|
||||||
|
}).catch(() => setSmtpLoaded(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
const [mapsKey, setMapsKey] = useState<string>('')
|
const [mapsKey, setMapsKey] = useState<string>('')
|
||||||
const [weatherKey, setWeatherKey] = useState<string>('')
|
const [weatherKey, setWeatherKey] = useState<string>('')
|
||||||
@@ -918,6 +928,51 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* SMTP / Notifications */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
{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 => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||||
|
<input
|
||||||
|
type={field.type || 'text'}
|
||||||
|
value={smtpValues[field.key] || ''}
|
||||||
|
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
|
||||||
|
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).catch(() => {})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await notificationsApi.testSmtp()
|
||||||
|
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||||
|
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||||
|
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
{t('admin.smtp.testButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Navbar from '../components/Layout/Navbar'
|
|||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
|
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 apiClient from '../api/client'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import type { UserWithOidc } from '../types'
|
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<Record<string, number> | null>(null)
|
||||||
|
const [addons, setAddons] = useState<Record<string, boolean>>({})
|
||||||
|
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/addons').then(r => {
|
||||||
|
const map: Record<string, boolean> = {}
|
||||||
|
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 <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{options.map(opt => (
|
||||||
|
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
|
||||||
|
<button onClick={() => toggle(opt.key)}
|
||||||
|
style={{
|
||||||
|
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||||
|
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
|
||||||
|
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||||
|
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage(): React.ReactElement {
|
export default function SettingsPage(): React.ReactElement {
|
||||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
|
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||||
@@ -474,6 +528,11 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Section title={t('settings.notifications')} icon={Lock}>
|
||||||
|
<NotificationPreferences t={t} memoriesEnabled={memoriesEnabled} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* Immich — only when Memories addon is enabled */}
|
{/* Immich — only when Memories addon is enabled */}
|
||||||
{memoriesEnabled && (
|
{memoriesEnabled && (
|
||||||
<Section title="Immich" icon={Camera}>
|
<Section title="Immich" icon={Camera}>
|
||||||
|
|||||||
25
server/package-lock.json
generated
25
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.6.2",
|
"version": "2.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.6.2",
|
"version": "2.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/unzipper": "^0.10.11",
|
"@types/unzipper": "^0.10.11",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
@@ -653,6 +655,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/qrcode": {
|
||||||
"version": "1.5.6",
|
"version": "1.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
@@ -2520,6 +2532,15 @@
|
|||||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.14",
|
"version": "3.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||||
|
|||||||
@@ -17,9 +17,10 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"node-fetch": "^2.7.0",
|
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/unzipper": "^0.10.11",
|
"@types/unzipper": "^0.10.11",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|||||||
@@ -333,6 +333,29 @@ function runMigrations(db: Database.Database): void {
|
|||||||
// Add target_date to bucket_list for optional visit planning
|
// 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 {}
|
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) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ app.use('/api/weather', weatherRoutes);
|
|||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/backup', backupRoutes);
|
app.use('/api/backup', backupRoutes);
|
||||||
|
|
||||||
|
import notificationRoutes from './routes/notifications';
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
|
||||||
// Serve static files in production
|
// Serve static files in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const publicPath = path.join(__dirname, '../public');
|
const publicPath = path.join(__dirname, '../public');
|
||||||
|
|||||||
@@ -515,17 +515,33 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
|||||||
res.json(result);
|
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<string, string> = {};
|
||||||
|
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) => {
|
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
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' });
|
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
|
||||||
const { allow_registration, allowed_file_types } = req.body;
|
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||||
if (allow_registration !== undefined) {
|
if (req.body[key] !== undefined) {
|
||||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
const val = String(req.body[key]);
|
||||||
}
|
// Don't save masked password
|
||||||
if (allowed_file_types !== undefined) {
|
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -419,6 +419,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
|
|||||||
const formatted = formatMessage(message);
|
const formatted = formatMessage(message);
|
||||||
res.status(201).json({ message: formatted });
|
res.status(201).json({ message: formatted });
|
||||||
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -155,6 +155,14 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
|
|||||||
|
|
||||||
res.json({ success: true, added });
|
res.json({ success: true, added });
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
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)
|
// Remove a photo from a trip (own photos only)
|
||||||
|
|||||||
58
server/src/routes/notifications.ts
Normal file
58
server/src/routes/notifications.ts
Normal file
@@ -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;
|
||||||
@@ -278,6 +278,18 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
|||||||
|
|
||||||
res.json({ assignees: rows });
|
res.json({ assignees: rows });
|
||||||
broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
|
|
||||||
res.status(201).json({ reservation });
|
res.status(201).json({ reservation });
|
||||||
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
||||||
|
|
||||||
|
// Notify trip members about new booking
|
||||||
|
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)
|
// Batch update day_plan_position for multiple reservations (must be before /:id)
|
||||||
|
|||||||
@@ -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);
|
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 } });
|
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 });
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -349,6 +349,11 @@ router.post('/invite', (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
} catch { /* websocket not available */ }
|
} 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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
297
server/src/services/notifications.ts
Normal file
297
server/src/services/notifications.ts
Normal file
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number> {
|
||||||
|
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<EventType, string> = {
|
||||||
|
trip_invite: 'notify_trip_invite',
|
||||||
|
booking_change: 'notify_booking_change',
|
||||||
|
trip_reminder: 'notify_trip_reminder',
|
||||||
|
vacay_invite: 'notify_vacay_invite',
|
||||||
|
photos_shared: 'notify_photos_shared',
|
||||||
|
collab_message: 'notify_collab_message',
|
||||||
|
packing_tagged: 'notify_packing_tagged',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Email i18n strings ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EmailStrings { footer: string; manage: string; madeWith: string; openTrek: string }
|
||||||
|
|
||||||
|
const I18N: Record<string, EmailStrings> = {
|
||||||
|
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<string, string>) => EventText
|
||||||
|
|
||||||
|
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
|
||||||
|
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<string, string>): 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 `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 40px 20px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
|
||||||
|
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
|
||||||
|
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
|
||||||
|
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource & Exploration Kit</div>
|
||||||
|
</td></tr>
|
||||||
|
<!-- Content -->
|
||||||
|
<tr><td style="padding: 32px 32px 16px;">
|
||||||
|
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${subject}</h1>
|
||||||
|
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${body}</p>
|
||||||
|
</td></tr>
|
||||||
|
<!-- CTA -->
|
||||||
|
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
|
||||||
|
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
|
||||||
|
</td></tr>` : ''}
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
|
||||||
|
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
|
||||||
|
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">♥</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send functions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string>): Promise<void> {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user