mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Finish the NestJS migration — drop the legacy Express app
NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.
- Platform/transport routes extracted to nest/platform/platform.routes.ts
and mounted before app.init() — Nest's router answers an unmatched
request with a 404, so a route registered after init is never reached.
The SPA fallback is a NotFoundException filter and the catch-all uses a
RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
gallery's Immich/Synology proxy), addons (GET /api/addons) and the
cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
so upload rejections keep their 400/413 { error } body and non-ASCII
filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
app; removed the now-meaningless Express-vs-Nest parity tests and a few
orphaned client components.
This commit is contained in:
@@ -1,95 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
const CURRENCIES = [
|
||||
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||
]
|
||||
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||
|
||||
export default function CurrencyWidget() {
|
||||
const { t, locale } = useTranslation()
|
||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||
const [amount, setAmount] = useState('100')
|
||||
const [rate, setRate] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchRate = useCallback(async () => {
|
||||
if (from === to) { setRate(1); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
||||
const data = await resp.json()
|
||||
setRate(data.rates?.[to] || null)
|
||||
} catch { setRate(null) }
|
||||
finally { setLoading(false) }
|
||||
}, [from, to])
|
||||
|
||||
useEffect(() => { fetchRate() }, [fetchRate])
|
||||
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
|
||||
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
|
||||
|
||||
const swap = () => { setFrom(to); setTo(from) }
|
||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||
const formatNumber = (num) => {
|
||||
if (!num || num === '—') return '—'
|
||||
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
const result = rawResult
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
|
||||
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* From / Swap / To */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||
</div>
|
||||
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
|
||||
<ArrowRightLeft size={13} />
|
||||
</button>
|
||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
||||
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
|
||||
</p>
|
||||
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import TimezoneWidget from './TimezoneWidget'
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||
})
|
||||
|
||||
describe('TimezoneWidget', () => {
|
||||
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
|
||||
render(<TimezoneWidget />)
|
||||
expect(document.body).toBeInTheDocument()
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
|
||||
render(<TimezoneWidget />)
|
||||
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
|
||||
expect(timeElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
|
||||
localStorage.clear()
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('New York')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
// Find and click Berlin in the popular zones list
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||
await user.click(berlinButton)
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
// Panel should be closed
|
||||
expect(screen.queryByText('Custom Timezone')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
// Type label and timezone
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(labelInput, 'My City')
|
||||
await user.type(tzInput, 'Europe/Paris')
|
||||
// Click Add
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText('My City')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(tzInput, 'Invalid/Timezone')
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Default zones include New York (America/New_York)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(tzInput, 'America/New_York')
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
|
||||
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
|
||||
// Remove buttons for New York and Tokyo come after the Plus button
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
|
||||
await user.click(allButtons[1])
|
||||
expect(screen.queryByText('New York')).toBeNull()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||
await user.click(berlinButton)
|
||||
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
|
||||
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(labelInput, 'Singapore')
|
||||
await user.type(tzInput, 'Asia/Singapore')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(await screen.findByText('Singapore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock, Plus, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const POPULAR_ZONES = [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
{ label: 'London', tz: 'Europe/London' },
|
||||
{ label: 'Berlin', tz: 'Europe/Berlin' },
|
||||
{ label: 'Paris', tz: 'Europe/Paris' },
|
||||
{ label: 'Dubai', tz: 'Asia/Dubai' },
|
||||
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
|
||||
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
|
||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
{ label: 'Sydney', tz: 'Australia/Sydney' },
|
||||
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
|
||||
{ label: 'Chicago', tz: 'America/Chicago' },
|
||||
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
|
||||
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
|
||||
{ label: 'Singapore', tz: 'Asia/Singapore' },
|
||||
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
|
||||
{ label: 'Seoul', tz: 'Asia/Seoul' },
|
||||
{ label: 'Moscow', tz: 'Europe/Moscow' },
|
||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||
]
|
||||
|
||||
function getTime(tz, locale, is12h) {
|
||||
try {
|
||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
} catch { return '—' }
|
||||
}
|
||||
|
||||
function getOffset(tz) {
|
||||
try {
|
||||
const now = new Date()
|
||||
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
|
||||
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
|
||||
const diff = (remote - local) / 3600000
|
||||
const sign = diff >= 0 ? '+' : ''
|
||||
return `${sign}${diff}h`
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
export default function TimezoneWidget() {
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [zones, setZones] = useState(() => {
|
||||
const saved = localStorage.getItem('dashboard_timezones')
|
||||
return saved ? JSON.parse(saved) : [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
]
|
||||
})
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [customLabel, setCustomLabel] = useState('')
|
||||
const [customTz, setCustomTz] = useState('')
|
||||
const [customError, setCustomError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||
return () => clearInterval(i)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||
}, [zones])
|
||||
|
||||
const isValidTz = (tz: string) => {
|
||||
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
||||
}
|
||||
|
||||
const addCustomZone = () => {
|
||||
const tz = customTz.trim()
|
||||
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
||||
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
||||
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
||||
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
||||
setZones([...zones, { label, tz }])
|
||||
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
||||
}
|
||||
|
||||
const addZone = (zone) => {
|
||||
if (!zones.find(z => z.tz === zone.tz)) {
|
||||
setZones([...zones, zone])
|
||||
}
|
||||
setShowAdd(false)
|
||||
}
|
||||
|
||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||
|
||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Local time */}
|
||||
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
|
||||
</div>
|
||||
|
||||
{/* Zone list */}
|
||||
<div className="space-y-2">
|
||||
{zones.map(z => (
|
||||
<div key={z.tz} className="flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||
</div>
|
||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add zone dropdown */}
|
||||
{showAdd && (
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{/* Custom timezone */}
|
||||
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
||||
<div className="space-y-1.5">
|
||||
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
||||
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
||||
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
||||
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
||||
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
||||
<button onClick={addCustomZone}
|
||||
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{t('dashboard.timezoneCustomAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Popular zones */}
|
||||
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||
<button key={z.tz} onClick={() => addZone(z)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<span className="font-medium">{z.label}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import MobileTopHeader from './MobileTopHeader';
|
||||
|
||||
describe('MobileTopHeader', () => {
|
||||
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
|
||||
render(<MobileTopHeader title="Journeys" />);
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading.textContent).toBe('Journeys');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
|
||||
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
|
||||
expect(screen.getByText('3 trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
|
||||
const { container } = render(<MobileTopHeader title="Journeys" />);
|
||||
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
|
||||
expect(subtitleEl).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
|
||||
render(
|
||||
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
|
||||
return (
|
||||
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
|
||||
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,789 +0,0 @@
|
||||
// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import MemoriesPanel from './MemoriesPanel';
|
||||
|
||||
// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering
|
||||
vi.mock('../../api/authUrl', () => ({
|
||||
fetchImageAsBlob: vi.fn().mockResolvedValue('blob:mock-url'),
|
||||
clearImageQueue: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
tripId: 1,
|
||||
startDate: '2025-03-01',
|
||||
endDate: '2025-03-10',
|
||||
};
|
||||
|
||||
// Reusable provider object to configure a connected Immich instance
|
||||
const immichAddon = {
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: { status_get: '/integrations/memories/immich/status' },
|
||||
};
|
||||
|
||||
// Handlers that simulate a connected provider with no photos/links
|
||||
const connectedHandlers = [
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ addons: [immichAddon] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/status', () =>
|
||||
HttpResponse.json({ connected: true })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Seed a default logged-in user
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }) });
|
||||
});
|
||||
|
||||
describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-001: Shows loading state on initial render', () => {
|
||||
// Use a delayed response so loading stays true long enough to assert
|
||||
server.use(
|
||||
http.get('/api/addons', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ addons: [] });
|
||||
}),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Spinner is rendered synchronously — loading state starts as true
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// "Photo provider not connected" — no providers, falls back to generic label
|
||||
await screen.findByText('Photo provider not connected');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'abc',
|
||||
provider: 'immich',
|
||||
user_id: 2,
|
||||
username: 'Alice',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Alice's username is rendered as an avatar tooltip in the gallery
|
||||
await screen.findByText('Alice');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-004: Shows empty gallery state when connected but no photos', async () => {
|
||||
server.use(...connectedHandlers);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Provider is connected so the gallery renders — but no photos → empty state
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-006: Sync button calls the sync endpoint', async () => {
|
||||
let syncCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => {
|
||||
syncCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
const syncBtn = screen.getByTitle('Sync album');
|
||||
await userEvent.click(syncBtn);
|
||||
|
||||
await waitFor(() => expect(syncCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-007: Unlink button calls the delete endpoint', async () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
// The unlink button is only shown when link.user_id === currentUser.id
|
||||
const unlinkBtn = screen.getByTitle('Unlink album');
|
||||
await userEvent.click(unlinkBtn);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
|
||||
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Default sort is ascending ("Oldest first")
|
||||
const sortBtn = await screen.findByText('Oldest first');
|
||||
|
||||
await userEvent.click(sortBtn);
|
||||
|
||||
// After toggle, button label switches to "Newest first"
|
||||
expect(screen.getByText('Newest first')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Wait for the empty gallery to load
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
// Both the header button and gallery CTA say "Add photos" — click the first
|
||||
const addBtns = screen.getAllByText('Add photos');
|
||||
await userEvent.click(addBtns[0]);
|
||||
|
||||
// Picker header is now visible
|
||||
await screen.findByText('Select photos from Immich');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const addBtns = screen.getAllByText('Add photos');
|
||||
await userEvent.click(addBtns[0]);
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Click Cancel in the picker header
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
// Gallery is restored
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({ albums: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
|
||||
// Album picker header appears
|
||||
await screen.findByText('Select Immich Album');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 0,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Share-toggle button appears with correct title (not shared → "Share photos")
|
||||
await screen.findByTitle('Share photos');
|
||||
|
||||
// "Private" label is shown on unshared own photos
|
||||
expect(screen.getByText('Private')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-013: toggleSharing calls the PUT sharing endpoint', async () => {
|
||||
let putCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 0,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
const shareBtn = await screen.findByTitle('Share photos');
|
||||
await userEvent.click(shareBtn);
|
||||
|
||||
await waitFor(() => expect(putCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-014: removePhoto calls the DELETE photos endpoint', async () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Wait for the share/stop-sharing button to confirm the gallery has rendered
|
||||
await screen.findByTitle('Stop sharing');
|
||||
|
||||
// The remove button is the second action button in the hover overlay — no title, just an X icon
|
||||
// Get all buttons and click the one after the share toggle
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing');
|
||||
// The remove button immediately follows the share button in the DOM
|
||||
await userEvent.click(allBtns[shareIdx + 1]);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-015: Picker displays assets grouped by month', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Month group header appears after photos load
|
||||
await screen.findByText(/March.*2025|2025.*March/);
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-016: Album picker lists available albums with asset count', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({
|
||||
albums: [
|
||||
{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
|
||||
await screen.findByText('Summer 2025');
|
||||
// Asset count is rendered next to the album name
|
||||
expect(screen.getByText(/42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-017: ProviderTabs appear in picker when multiple providers are connected', async () => {
|
||||
const immich2Addon = {
|
||||
id: 'immich2',
|
||||
name: 'Immich2',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: { status_get: '/integrations/memories/immich2/status' },
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ addons: [immichAddon, immich2Addon] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })),
|
||||
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
// With multiple providers the picker header uses the "multiple" translation
|
||||
await screen.findByText('Select Photos');
|
||||
|
||||
// Both provider name tabs are rendered inside the picker
|
||||
expect(screen.getByRole('button', { name: 'Immich' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Immich2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Location dropdown shows "All locations" option when there are 2+ distinct cities
|
||||
await screen.findByText('All locations');
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-019: Full picker flow: select photo → confirm dialog → execute add', async () => {
|
||||
let addPhotosCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => {
|
||||
addPhotosCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Wait for the picker asset thumbnail to render (ProviderImg sets src after blob resolves)
|
||||
// img has alt="" so findByRole('img') won't work — use findByAltText instead
|
||||
const thumbnail = await screen.findByAltText('');
|
||||
|
||||
// Click the thumbnail — bubbles up to the parent div's onClick to select it
|
||||
await userEvent.click(thumbnail);
|
||||
|
||||
// "1 selected" count appears and "Add 1 photos" button is active
|
||||
await screen.findByText(/1\s+selected/);
|
||||
await userEvent.click(screen.getByText('Add 1 photos'));
|
||||
|
||||
// Confirm share dialog appears
|
||||
await screen.findByText('Share with trip members?');
|
||||
|
||||
// Click the confirm "Share photos" button to execute
|
||||
await userEvent.click(screen.getByText('Share photos'));
|
||||
|
||||
await waitFor(() => expect(addPhotosCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-020: "All photos" filter tab makes an unfiltered search', async () => {
|
||||
let searchCount = 0;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () => {
|
||||
searchCount++;
|
||||
return HttpResponse.json({ assets: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Click "All photos" — triggers a second loadPickerPhotos(false) call
|
||||
await userEvent.click(screen.getByText('All photos'));
|
||||
|
||||
await waitFor(() => expect(searchCount).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// "Trip dates" tab is absent when dates are not set
|
||||
expect(screen.queryByText(/Trip dates/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('All photos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-022: Provider with no status_get URL shows not-connected', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [
|
||||
{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Provider name shown in the not-connected message when exactly 1 enabled provider
|
||||
await screen.findByText('MyApp not connected');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'asset1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Gallery shows own photo — "Stop sharing" title confirms it's loaded
|
||||
await screen.findByTitle('Stop sharing');
|
||||
|
||||
// Open picker from the header button (only 1 "Add photos" button since photos > 0)
|
||||
await userEvent.click(screen.getByText('Add photos'));
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// The asset already in the gallery shows the "Added" overlay in the picker
|
||||
await screen.findByText('Added');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
const select = await screen.findByRole('combobox');
|
||||
|
||||
// Change filter to a specific city
|
||||
await userEvent.selectOptions(select, 'Paris');
|
||||
|
||||
expect(select).toHaveValue('Paris');
|
||||
});
|
||||
|
||||
it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 2,
|
||||
username: 'Alice',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
// Other user's username is shown in parentheses
|
||||
expect(screen.getByText('(Alice)')).toBeInTheDocument();
|
||||
|
||||
// Unlink button is NOT shown for another user's album link
|
||||
expect(screen.queryByTitle('Unlink album')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-026: Linking an album calls the album-links POST endpoint', async () => {
|
||||
let linkCalled = false;
|
||||
// Track whether POST has been made so the GET can return different data
|
||||
let albumLinked = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({
|
||||
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
|
||||
linkCalled = true;
|
||||
albumLinked = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
// Return empty before POST, linked album after POST
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
|
||||
if (!albumLinked) return HttpResponse.json({ links: [] });
|
||||
return HttpResponse.json({
|
||||
links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }],
|
||||
});
|
||||
}),
|
||||
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () =>
|
||||
HttpResponse.json({ ok: true })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
await screen.findByText('Summer 2025');
|
||||
|
||||
// Click the album button to link it (album is not yet linked → button is enabled)
|
||||
await userEvent.click(screen.getByText('Summer 2025'));
|
||||
|
||||
await waitFor(() => expect(linkCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({ albums: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
await screen.findByText('Select Immich Album');
|
||||
|
||||
// Click Cancel to dismiss without linking
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
// Gallery is restored
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string
|
||||
size?: number
|
||||
title?: string
|
||||
className?: string
|
||||
onCopy?: () => void
|
||||
}
|
||||
|
||||
// Button that morphs between copy icon and check icon for 1.5s after click.
|
||||
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
onCopy?.()
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}, [value, onCopy])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: copied ? '#22c55e' : 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Copy size={size} style={{
|
||||
position: 'absolute',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
opacity: copied ? 0 : 1,
|
||||
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
|
||||
}} />
|
||||
<Check size={size} style={{
|
||||
position: 'absolute',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
opacity: copied ? 1 : 0,
|
||||
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
|
||||
strokeWidth: 2.5,
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyButton
|
||||
@@ -1,36 +0,0 @@
|
||||
import React, { useState, type ImgHTMLAttributes } from 'react'
|
||||
|
||||
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
containerClassName?: string
|
||||
containerStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
|
||||
export function LoadingImage({
|
||||
containerClassName, containerStyle, className, style, onLoad, ...imgProps
|
||||
}: LoadingImageProps): React.ReactElement {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
return (
|
||||
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
|
||||
{!loaded && (
|
||||
<div
|
||||
className="trek-skeleton"
|
||||
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
{...imgProps}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
}}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingImage
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* usePendingMutations — returns the set of entity IDs that have a pending
|
||||
* or syncing mutation for a given trip.
|
||||
*
|
||||
* Components use this to render a clock/pending indicator on list rows.
|
||||
* Polls Dexie every 2 s so the indicator clears automatically once synced.
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { mutationQueue } from '../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 2_000
|
||||
|
||||
export function usePendingMutations(tripId: number): Set<number> {
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function refresh() {
|
||||
const pending = await mutationQueue.pending(tripId)
|
||||
if (cancelled) return
|
||||
|
||||
const ids = new Set<number>()
|
||||
for (const m of pending) {
|
||||
// Extract entity id from the mutation URL (last numeric segment)
|
||||
const match = m.url.match(/\/(\d+)$/)
|
||||
if (match) ids.add(Number(match[1]))
|
||||
// Also include tempId for offline-created items
|
||||
if (m.tempId !== undefined) ids.add(m.tempId)
|
||||
}
|
||||
setPendingIds(ids)
|
||||
}
|
||||
|
||||
refresh()
|
||||
const timer = setInterval(refresh, POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
return pendingIds
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { swapItems } from '../../../src/utils/reorder';
|
||||
|
||||
// FE-UTIL-020 onwards
|
||||
|
||||
const items = [
|
||||
{ id: 10 },
|
||||
{ id: 20 },
|
||||
{ id: 30 },
|
||||
{ id: 40 },
|
||||
];
|
||||
|
||||
describe('swapItems', () => {
|
||||
it('FE-UTIL-020: swaps item up with its predecessor', () => {
|
||||
const result = swapItems(items, 1, 'up');
|
||||
expect(result).toEqual([20, 10, 30, 40]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-021: swaps item down with its successor', () => {
|
||||
const result = swapItems(items, 1, 'down');
|
||||
expect(result).toEqual([10, 30, 20, 40]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-022: returns null when moving first item up (out of bounds)', () => {
|
||||
expect(swapItems(items, 0, 'up')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-023: returns null when moving last item down (out of bounds)', () => {
|
||||
expect(swapItems(items, items.length - 1, 'down')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-024: swaps first and second items when moving index 1 up', () => {
|
||||
const result = swapItems(items, 1, 'up');
|
||||
expect(result![0]).toBe(20);
|
||||
expect(result![1]).toBe(10);
|
||||
});
|
||||
|
||||
it('FE-UTIL-025: returns an array of IDs (not objects)', () => {
|
||||
const result = swapItems(items, 0, 'down');
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(typeof result![0]).toBe('number');
|
||||
});
|
||||
|
||||
it('FE-UTIL-026: does not mutate the original array', () => {
|
||||
const original = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
const snapshot = original.map((o) => o.id);
|
||||
swapItems(original, 0, 'down');
|
||||
expect(original.map((o) => o.id)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('FE-UTIL-027: returns null for a single-element array moving down', () => {
|
||||
expect(swapItems([{ id: 5 }], 0, 'down')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-028: returns null for a single-element array moving up', () => {
|
||||
expect(swapItems([{ id: 5 }], 0, 'up')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-029: swaps last two items when moving second-to-last down', () => {
|
||||
const result = swapItems(items, items.length - 2, 'down');
|
||||
expect(result).toEqual([10, 20, 40, 30]);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:ws": "vitest run tests/websocket",
|
||||
"test:parity": "vitest run tests/parity",
|
||||
"test:e2e": "vitest run tests/e2e",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ExpressAdapter } from '@nestjs/platform-express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { AppModule } from './nest/app.module';
|
||||
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
|
||||
import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes';
|
||||
|
||||
/**
|
||||
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
|
||||
* former Express app is gone. One builder is shared by the production bootstrap
|
||||
* (index.ts) and the integration-test harness so the two can never drift.
|
||||
*
|
||||
* Composition order is load-bearing. Everything except the SPA index.html fallback
|
||||
* is registered on the underlying Express instance BEFORE `app.init()`, because
|
||||
* Nest's router terminates an unmatched request by throwing NotFoundException — it
|
||||
* does NOT fall through to a route registered after init, so a post-init Express
|
||||
* route is unreachable. The platform routes are all specific paths (/uploads/*,
|
||||
* /api/health, /mcp, /.well-known/*, /oauth/{authorize,register,consent}) so they
|
||||
* match their own requests and `next()` everything else through to the Nest
|
||||
* controllers registered during init.
|
||||
*
|
||||
* 1. applyGlobalMiddleware — helmet/CSP, CORS, HSTS, forced-HTTPS, the global MFA
|
||||
* policy, request logging + cookie-parser. `bodyParser: false` so Nest does its
|
||||
* own parsing and the raw /mcp body reaches the MCP handler unparsed.
|
||||
* 2. applyPlatformUploads — the static + guarded /uploads/* routes.
|
||||
* 3. applyPlatformTransport — /api/health, the OAuth/MCP SDK + /.well-known
|
||||
* metadata, the /mcp routes, the /oauth/consent COOP header.
|
||||
* 4. applyPlatformStatic — the production built-client static assets (so a real
|
||||
* asset request returns the file before the Nest router 404s it).
|
||||
* 5. app.init() — registers every migrated /api domain (the Nest controllers).
|
||||
*
|
||||
* The SPA index.html fallback (unmatched GET → index.html in production) is the
|
||||
* SpaFallbackFilter (APP_FILTER in AppModule); the global error envelope is the
|
||||
* TrekExceptionFilter (also APP_FILTER).
|
||||
*/
|
||||
export async function buildApp(): Promise<INestApplication> {
|
||||
const app = await NestFactory.create(AppModule, new ExpressAdapter());
|
||||
const instance = app.getHttpAdapter().getInstance();
|
||||
applyGlobalMiddleware(instance, { bodyParser: false });
|
||||
applyPlatformUploads(instance);
|
||||
applyPlatformTransport(instance);
|
||||
applyPlatformStatic(instance);
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
+8
-46
@@ -3,14 +3,8 @@ import 'dotenv/config';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ExpressAdapter } from '@nestjs/platform-express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { createApp } from './app';
|
||||
import { AppModule } from './nest/app.module';
|
||||
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
|
||||
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
|
||||
import { buildApp } from './bootstrap';
|
||||
|
||||
// Create upload and data directories on startup
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
@@ -25,11 +19,6 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
|
||||
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
|
||||
// everything else falls through to this app via a fallback middleware.
|
||||
const legacyApp = createApp();
|
||||
|
||||
import * as scheduler from './scheduler';
|
||||
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
||||
|
||||
@@ -61,11 +50,7 @@ const onListen = () => {
|
||||
'──────────────────────────────────────',
|
||||
];
|
||||
banner.forEach(l => console.log(l));
|
||||
sLogInfo(
|
||||
NEST_PREFIXES.length
|
||||
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
|
||||
: 'NestJS prefixes: none — all routes served by the legacy Express app',
|
||||
);
|
||||
sLogInfo('NestJS serving all routes (Express decommissioned)');
|
||||
if (process.env.APP_URL) {
|
||||
let parsedAppUrl: URL | null = null;
|
||||
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
|
||||
@@ -105,34 +90,13 @@ let server: http.Server;
|
||||
let nestApp: INestApplication;
|
||||
|
||||
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
|
||||
const NEST_PREFIXES = getNestPrefixes();
|
||||
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
// Nest runs on its own Express instance (bodyParser off so request bodies reach
|
||||
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
|
||||
// Nest body parsing is safe here: the dispatcher only forwards migrated
|
||||
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
|
||||
// is reached separately and never passes through Nest's parser.
|
||||
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
|
||||
const nestInstance = nestApp.getHttpAdapter().getInstance();
|
||||
// Apply the SAME global request pipeline the legacy app uses (helmet/CSP, CORS,
|
||||
// HSTS, forced-HTTPS, the global MFA policy, request logging + cookie-parser) so a
|
||||
// migrated Nest route is protected identically to the legacy fallback. Without this
|
||||
// the dispatcher forwards Nest paths straight to this instance, bypassing all of it.
|
||||
// Nest does its own body parsing, so bodyParser:false avoids parsing twice.
|
||||
applyGlobalMiddleware(nestInstance, { bodyParser: false });
|
||||
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
|
||||
await nestApp.init();
|
||||
|
||||
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
|
||||
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
|
||||
// only applies within migrated prefixes.
|
||||
const top = express();
|
||||
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
|
||||
top.use(legacyApp);
|
||||
|
||||
server = http.createServer(top);
|
||||
// The whole surface runs on the single NestJS app now (Express decommissioned):
|
||||
// global pipeline + /uploads + every /api domain + the platform/transport routes
|
||||
// (/mcp, /.well-known, OAuth SDK, SPA catch-all). buildApp() owns the composition
|
||||
// order; it is shared with the integration-test harness so they can't drift.
|
||||
nestApp = await buildApp();
|
||||
server = http.createServer(nestApp.getHttpAdapter().getInstance());
|
||||
if (HOST) server.listen(PORT, HOST, onListen);
|
||||
else server.listen(PORT, onListen);
|
||||
}
|
||||
@@ -165,5 +129,3 @@ function shutdown(signal: string): void {
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
export default legacyApp;
|
||||
|
||||
@@ -380,6 +380,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
|
||||
const shareLink = getJourneyShareLink(journeyId);
|
||||
return ok({ shareLink });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { AddonsService } from './addons.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
/**
|
||||
* GET /api/addons — the enabled trip add-ons + photo providers feed.
|
||||
* Byte-identical to the legacy inline handler in server/src/app.ts
|
||||
* (authenticate-gated, returns { collabFeatures, addons: [...] }).
|
||||
*
|
||||
* Distinct from the addon sub-mounts /api/addons/atlas and /api/addons/vacay
|
||||
* (their own Nest modules); the strangler routes only the EXACT /api/addons here.
|
||||
*/
|
||||
@Controller('api/addons')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AddonsController {
|
||||
constructor(private readonly addons: AddonsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.addons.list();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AddonsController } from './addons.controller';
|
||||
import { AddonsService } from './addons.service';
|
||||
|
||||
/**
|
||||
* GET /api/addons — enabled add-ons + photo providers (was an inline handler in
|
||||
* server/src/app.ts). The addon sub-features (atlas, vacay) keep their own
|
||||
* modules; this only serves the EXACT /api/addons listing.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AddonsController],
|
||||
providers: [AddonsService],
|
||||
})
|
||||
export class AddonsModule {}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import type { Addon } from '../../types';
|
||||
import { getCollabFeatures } from '../../services/adminService';
|
||||
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the enabled-addons + photo-provider read that the legacy
|
||||
* inline `GET /api/addons` handler performed (server/src/app.ts). The SQL,
|
||||
* ordering, boolean coercions and the merged photo-provider entries are
|
||||
* reproduced 1:1 so the body is byte-identical for the client.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AddonsService {
|
||||
list() {
|
||||
const addons = db
|
||||
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
|
||||
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||
const providers = db
|
||||
.prepare(
|
||||
`SELECT id, name, icon, enabled, sort_order
|
||||
FROM photo_providers
|
||||
WHERE enabled = 1
|
||||
ORDER BY sort_order, id`,
|
||||
)
|
||||
.all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
|
||||
const fields = db
|
||||
.prepare(
|
||||
`SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id`,
|
||||
)
|
||||
.all() as Array<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
hint?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order: number;
|
||||
}>;
|
||||
|
||||
const fieldsByProvider = new Map<string, typeof fields>();
|
||||
for (const field of fields) {
|
||||
const arr = fieldsByProvider.get(field.provider_id) || [];
|
||||
arr.push(field);
|
||||
fieldsByProvider.set(field.provider_id, arr);
|
||||
}
|
||||
|
||||
return {
|
||||
collabFeatures: getCollabFeatures(),
|
||||
addons: [
|
||||
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
|
||||
...providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: getPhotoProviderConfig(p.id),
|
||||
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
placeholder: f.placeholder || '',
|
||||
hint: f.hint || null,
|
||||
required: !!f.required,
|
||||
secret: !!f.secret,
|
||||
settings_key: f.settings_key || null,
|
||||
payload_key: f.payload_key || null,
|
||||
sort_order: f.sort_order,
|
||||
})),
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { TodoModule } from './todo/todo.module';
|
||||
import { CollabModule } from './collab/collab.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { PhotosModule } from './photos/photos.module';
|
||||
import { MemoriesModule } from './memories/memories.module';
|
||||
import { JourneyModule } from './journey/journey.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
@@ -32,7 +33,9 @@ import { AuthModule } from './auth/auth.module';
|
||||
import { OidcModule } from './oidc/oidc.module';
|
||||
import { OauthModule } from './oauth/oauth.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AddonsModule } from './addons/addons.module';
|
||||
import { TrekExceptionFilter } from './common/trek-exception.filter';
|
||||
import { SpaFallbackFilter } from './platform/spa-fallback.filter';
|
||||
import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
|
||||
/**
|
||||
@@ -40,13 +43,17 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
* (weather, notifications, ...) get registered here as they are migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule],
|
||||
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
// Global error-envelope normaliser (DI-registered so it also catches
|
||||
// framework-level exceptions like the not-found handler).
|
||||
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
|
||||
// SPA fallback: serves index.html for unmatched GETs in production (the Nest
|
||||
// equivalent of the legacy Express app.get('*') catch-all). @Catch(NotFoundException)
|
||||
// is more specific than TrekExceptionFilter, so Nest routes 404s here.
|
||||
{ provide: APP_FILTER, useClass: SpaFallbackFilter },
|
||||
// Replays the X-Idempotency-Key the client sends on every write, matching
|
||||
// the legacy applyIdempotency middleware so retried mutations don't double-apply.
|
||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||
|
||||
@@ -33,6 +33,7 @@ const NOTE_UPLOAD = {
|
||||
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
|
||||
}),
|
||||
limits: { fileSize: MAX_NOTE_FILE_SIZE },
|
||||
defParamCharset: 'utf8', // parity with legacy routes/collab.ts — preserve non-ASCII original filenames
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
|
||||
|
||||
@@ -1,42 +1,69 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { MulterError } from 'multer';
|
||||
|
||||
/**
|
||||
* Normalises every Nest exception to TREK's legacy error envelope so migrated
|
||||
* routes are byte-identical for the client:
|
||||
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
|
||||
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
|
||||
* This replaces Nest's default { statusCode, message, error } body, which the
|
||||
* TREK client does not expect.
|
||||
* routes are byte-identical for the client. This mirrors the legacy global
|
||||
* Express error handler (server/src/app.ts) exactly:
|
||||
* - multer errors -> 413 (LIMIT_FILE_SIZE) / 400, body { error: <multer message> }
|
||||
* - { error, code? } bodies -> passed through unchanged (auth guards, ZodValidationPipe)
|
||||
* - other HttpExceptions -> { error: <message> } at the same status
|
||||
* - plain errors w/ statusCode/status -> that status, { error: <message> } for 4xx
|
||||
* - everything else -> 500 { error: 'Internal server error' }
|
||||
*
|
||||
* Without the multer + statusCode handling, file-upload rejections (multer's
|
||||
* LIMIT_FILE_SIZE and the fileFilter errors that carry `statusCode = 400`) would
|
||||
* collapse to Nest's `{ statusCode, message, error }` 413 body or a 500, diverging
|
||||
* from the legacy `{ error: 'File too large' }` (413) and `{ error: '<reason>' }` (400).
|
||||
*/
|
||||
@Catch()
|
||||
export class TrekExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const res = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
// 1. Raw multer errors that slipped past @nestjs/platform-express's
|
||||
// transformException (it leaves codes it does not recognise untouched).
|
||||
// Legacy: LIMIT_FILE_SIZE -> 413, everything else -> 400, body { error: message }.
|
||||
if (exception instanceof MulterError) {
|
||||
const status = exception.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||
res.status(status).json({ error: exception.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
const body = exception.getResponse();
|
||||
|
||||
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
|
||||
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
|
||||
res.status(status).json(body);
|
||||
if (body && typeof body === 'object') {
|
||||
const obj = body as Record<string, unknown>;
|
||||
// TREK-native shape ({ error } / { error, code } from guards + the Zod
|
||||
// pipe): pass through verbatim. Nest's own exceptions instead carry the
|
||||
// { statusCode, message, error } trio (incl. transformException's
|
||||
// PayloadTooLargeException for LIMIT_FILE_SIZE) and must be normalised.
|
||||
if ('error' in obj && !('statusCode' in obj) && !('message' in obj)) {
|
||||
res.status(status).json(obj);
|
||||
return;
|
||||
}
|
||||
const raw = obj.message ?? obj.error;
|
||||
const message =
|
||||
status < 500 ? (Array.isArray(raw) ? raw.join(', ') : String(raw ?? 'Error')) : 'Internal server error';
|
||||
res.status(status).json({ error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
|
||||
const message =
|
||||
status < 500
|
||||
? Array.isArray(raw)
|
||||
? raw.join(', ')
|
||||
: String(raw ?? 'Error')
|
||||
: 'Internal server error';
|
||||
const message = status < 500 ? String(body ?? 'Error') : 'Internal server error';
|
||||
res.status(status).json({ error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown/unhandled error — mirror the legacy 500 behaviour.
|
||||
console.error('Unhandled error:', exception);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
// 2. Plain errors carrying an explicit status (the fileFilter rejections set
|
||||
// `statusCode = 400`; transformException returns them unchanged). Legacy:
|
||||
// status = err.statusCode || err.status || 500; 4xx exposes err.message.
|
||||
const err = exception as { statusCode?: number; status?: number; message?: unknown } | null;
|
||||
const status = (err && (err.statusCode || err.status)) || 500;
|
||||
if (status >= 500) console.error('Unhandled error:', exception);
|
||||
const message = status < 500 ? String(err?.message ?? 'Error') : 'Internal server error';
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export class FilesDownloadController {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`);
|
||||
}
|
||||
|
||||
res.sendFile(resolved);
|
||||
// Serve with an explicit { root } + basename rather than an absolute path:
|
||||
// under the Nest ExpressAdapter, res.sendFile(absolutePath) resolves the
|
||||
// file relative to the (rewritten) req.url and fails with a spurious
|
||||
// "Not Found", whereas the root-relative form streams correctly. The
|
||||
// resolveFilePath guard above already pins this to the uploads dir.
|
||||
res.sendFile(path.basename(resolved), { root: path.dirname(resolved) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const UPLOAD = {
|
||||
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
|
||||
}),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const reject = () => {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { User } from '../../types';
|
||||
import { MemoriesService } from './memories.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { getClientIp } from '../../services/auditLog';
|
||||
|
||||
/**
|
||||
* /api/integrations/memories/immich — Immich connection, browse/search, asset
|
||||
* proxy and album linking.
|
||||
*
|
||||
* Byte-identical to the legacy Express router (server/src/routes/memories/immich.ts):
|
||||
* `/status` and `/test` answer 200 even on connection failure (the service shapes
|
||||
* `{ connected: false, ... }`); `/settings` PUT validates with a 400; the asset
|
||||
* routes do the 400 invalid-id guard then the canAccessUserPhoto 403 ('Forbidden')
|
||||
* before streaming or returning info; the album sync answers 200 then broadcasts.
|
||||
* The legacy `canAccessTrip` import there is dead code — intentionally not ported.
|
||||
*/
|
||||
@Controller('api/integrations/memories/immich')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ImmichMemoriesController {
|
||||
constructor(private readonly memories: MemoriesService) {}
|
||||
|
||||
@Get('settings')
|
||||
getSettings(@CurrentUser() user: User) {
|
||||
return this.memories.immichGetConnectionSettings(user.id);
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
async putSettings(
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: { immich_url?: string; immich_api_key?: string; auto_upload?: unknown },
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { immich_url, immich_api_key, auto_upload } = body;
|
||||
const result = await this.memories.immichSaveSettings(user.id, immich_url, immich_api_key, getClientIp(req));
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
if (typeof auto_upload === 'boolean') {
|
||||
this.memories.immichSetAutoUpload(user.id, auto_upload);
|
||||
}
|
||||
if (result.warning) {
|
||||
res.json({ success: true, warning: result.warning });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
async getStatus(@CurrentUser() user: User) {
|
||||
return this.memories.immichGetConnectionStatus(user.id);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@HttpCode(200)
|
||||
async test(@Body() body: { immich_url?: string; immich_api_key?: string }) {
|
||||
const { immich_url, immich_api_key } = body;
|
||||
if (!immich_url || !immich_api_key) {
|
||||
return { connected: false, error: 'URL and API key required' };
|
||||
}
|
||||
return this.memories.immichTestConnection(immich_url, immich_api_key);
|
||||
}
|
||||
|
||||
@Get('browse')
|
||||
async browse(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
|
||||
const result = await this.memories.immichBrowseTimeline(user.id);
|
||||
if (result.error) {
|
||||
res.status(result.status!).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ buckets: result.buckets });
|
||||
}
|
||||
|
||||
@Post('search')
|
||||
@HttpCode(200)
|
||||
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
|
||||
const { from, to, size, page } = body as { from?: string; to?: string; size?: unknown; page?: unknown };
|
||||
const pageNum = Math.max(1, Number(page) || 1);
|
||||
const pageSize = Math.min(Number(size) || 50, 200);
|
||||
const result = await this.memories.immichSearchPhotos(user.id, from, to, pageNum, pageSize);
|
||||
if (result.error) {
|
||||
res.status(result.status!).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
|
||||
}
|
||||
|
||||
@Get('assets/:tripId/:assetId/:ownerId/info')
|
||||
async assetInfo(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('assetId') assetId: string,
|
||||
@Param('ownerId') ownerId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
if (!this.memories.immichIsValidAssetId(assetId)) {
|
||||
res.status(400).json({ error: 'Invalid asset ID' });
|
||||
return;
|
||||
}
|
||||
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
const result = await this.memories.immichGetAssetInfo(user.id, assetId, Number(ownerId));
|
||||
if (result.error) {
|
||||
res.status(result.status!).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result.data);
|
||||
}
|
||||
|
||||
@Get('assets/:tripId/:assetId/:ownerId/thumbnail')
|
||||
async assetThumbnail(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('assetId') assetId: string,
|
||||
@Param('ownerId') ownerId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
if (!this.memories.immichIsValidAssetId(assetId)) {
|
||||
res.status(400).json({ error: 'Invalid asset ID' });
|
||||
return;
|
||||
}
|
||||
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
await this.memories.immichStreamAsset(res, user.id, assetId, 'thumbnail', Number(ownerId));
|
||||
}
|
||||
|
||||
@Get('assets/:tripId/:assetId/:ownerId/original')
|
||||
async assetOriginal(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('assetId') assetId: string,
|
||||
@Param('ownerId') ownerId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
if (!this.memories.immichIsValidAssetId(assetId)) {
|
||||
res.status(400).json({ error: 'Invalid asset ID' });
|
||||
return;
|
||||
}
|
||||
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
await this.memories.immichStreamAsset(res, user.id, assetId, 'original', Number(ownerId));
|
||||
}
|
||||
|
||||
@Get('albums')
|
||||
async albums(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
|
||||
const result = await this.memories.immichListAlbums(user.id);
|
||||
if (result.error) {
|
||||
res.status(result.status!).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ albums: result.albums });
|
||||
}
|
||||
|
||||
@Get('albums/:albumId/photos')
|
||||
async albumPhotos(@CurrentUser() user: User, @Param('albumId') albumId: string, @Res() res: Response): Promise<void> {
|
||||
const result = await this.memories.immichGetAlbumPhotos(user.id, albumId);
|
||||
if (result.error) {
|
||||
res.status(result.status!).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ assets: result.assets });
|
||||
}
|
||||
|
||||
@Post('trips/:tripId/album-links/:linkId/sync')
|
||||
@HttpCode(200)
|
||||
async sync(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('linkId') linkId: string,
|
||||
@Headers('x-socket-id') sid: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = await this.memories.immichSyncAlbumAssets(tripId, linkId, user.id, sid);
|
||||
if (result.error) {
|
||||
res.status(result.status!).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, added: result.added, total: result.total });
|
||||
if (result.added! > 0) {
|
||||
this.memories.broadcast(tripId, 'memories:updated', { userId: user.id }, sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MemoriesService } from './memories.service';
|
||||
import { UnifiedMemoriesController } from './unified.controller';
|
||||
import { ImmichMemoriesController } from './immich.controller';
|
||||
import { SynologyMemoriesController } from './synology.controller';
|
||||
|
||||
/**
|
||||
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
|
||||
*
|
||||
* Ports the legacy Express router (routes/memories/unified.ts, which composes
|
||||
* immich.ts + synology.ts) to Nest, reusing services/memories/* unchanged. No
|
||||
* module-level addon gate — enablement is per-provider-row inside the services,
|
||||
* exactly as the legacy mount had it.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
|
||||
providers: [MemoriesService],
|
||||
})
|
||||
export class MemoriesModule {}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
listTripPhotos,
|
||||
listTripAlbumLinks,
|
||||
createTripAlbumLink,
|
||||
removeAlbumLink,
|
||||
addTripPhotos,
|
||||
removeTripPhoto,
|
||||
setTripPhotoSharing,
|
||||
} from '../../services/memories/unifiedService';
|
||||
import {
|
||||
getConnectionSettings,
|
||||
saveImmichSettings,
|
||||
setImmichAutoUpload,
|
||||
testConnection,
|
||||
getConnectionStatus,
|
||||
browseTimeline,
|
||||
searchPhotos,
|
||||
streamImmichAsset,
|
||||
listAlbums,
|
||||
getAlbumPhotos,
|
||||
syncAlbumAssets,
|
||||
getAssetInfo,
|
||||
isValidAssetId,
|
||||
} from '../../services/memories/immichService';
|
||||
import {
|
||||
getSynologySettings,
|
||||
updateSynologySettings,
|
||||
getSynologyStatus,
|
||||
testSynologyConnection,
|
||||
listSynologyAlbums,
|
||||
getSynologyAlbumPhotos,
|
||||
syncSynologyAlbumLink,
|
||||
searchSynologyPhotos,
|
||||
getSynologyAssetInfo,
|
||||
streamSynologyAsset,
|
||||
} from '../../services/memories/synologyService';
|
||||
import { canAccessUserPhoto } from '../../services/memories/helpersService';
|
||||
import type { Selection } from '../../services/memories/helpersService';
|
||||
import { broadcast } from '../../websocket';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing memories (photo-providers) services.
|
||||
* Every method delegates to the legacy `services/memories/*` code unchanged so
|
||||
* the provider logic, the per-provider access checks and the streaming helpers
|
||||
* behave byte-identically to the legacy Express routers. No new business logic
|
||||
* lives here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MemoriesService {
|
||||
// ── Access check (reused by both provider asset routes) ──────────────────
|
||||
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
|
||||
return canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider);
|
||||
}
|
||||
|
||||
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId?: string): void {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
// ── Unified ──────────────────────────────────────────────────────────────
|
||||
listTripPhotos(tripId: string, userId: number) {
|
||||
return listTripPhotos(tripId, userId);
|
||||
}
|
||||
|
||||
addTripPhotos(tripId: string, userId: number, shared: boolean, selections: Selection[], sid: string) {
|
||||
return addTripPhotos(tripId, userId, shared, selections, sid);
|
||||
}
|
||||
|
||||
setTripPhotoSharing(tripId: string, userId: number, photoId: number, shared: boolean) {
|
||||
return setTripPhotoSharing(tripId, userId, photoId, shared);
|
||||
}
|
||||
|
||||
removeTripPhoto(tripId: string, userId: number, photoId: number) {
|
||||
return removeTripPhoto(tripId, userId, photoId);
|
||||
}
|
||||
|
||||
listTripAlbumLinks(tripId: string, userId: number) {
|
||||
return listTripAlbumLinks(tripId, userId);
|
||||
}
|
||||
|
||||
createTripAlbumLink(tripId: string, userId: number, provider: unknown, albumId: unknown, albumName: unknown, passphrase?: string) {
|
||||
return createTripAlbumLink(tripId, userId, provider, albumId, albumName, passphrase);
|
||||
}
|
||||
|
||||
removeAlbumLink(tripId: string, linkId: string, userId: number) {
|
||||
return removeAlbumLink(tripId, linkId, userId);
|
||||
}
|
||||
|
||||
// ── Immich ─────────────────────────────────────────────────────────────────
|
||||
immichGetConnectionSettings(userId: number) {
|
||||
return getConnectionSettings(userId);
|
||||
}
|
||||
|
||||
immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null) {
|
||||
return saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
|
||||
}
|
||||
|
||||
immichSetAutoUpload(userId: number, enabled: boolean): void {
|
||||
setImmichAutoUpload(userId, enabled);
|
||||
}
|
||||
|
||||
immichGetConnectionStatus(userId: number) {
|
||||
return getConnectionStatus(userId);
|
||||
}
|
||||
|
||||
immichTestConnection(immichUrl: string, immichApiKey: string) {
|
||||
return testConnection(immichUrl, immichApiKey);
|
||||
}
|
||||
|
||||
immichBrowseTimeline(userId: number) {
|
||||
return browseTimeline(userId);
|
||||
}
|
||||
|
||||
immichSearchPhotos(userId: number, from: string | undefined, to: string | undefined, page: number, size: number) {
|
||||
return searchPhotos(userId, from, to, page, size);
|
||||
}
|
||||
|
||||
immichIsValidAssetId(assetId: string): boolean {
|
||||
return isValidAssetId(assetId);
|
||||
}
|
||||
|
||||
immichGetAssetInfo(userId: number, assetId: string, ownerId: number) {
|
||||
return getAssetInfo(userId, assetId, ownerId);
|
||||
}
|
||||
|
||||
immichStreamAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) {
|
||||
return streamImmichAsset(res, userId, assetId, kind, ownerId);
|
||||
}
|
||||
|
||||
immichListAlbums(userId: number) {
|
||||
return listAlbums(userId);
|
||||
}
|
||||
|
||||
immichGetAlbumPhotos(userId: number, albumId: string) {
|
||||
return getAlbumPhotos(userId, albumId);
|
||||
}
|
||||
|
||||
immichSyncAlbumAssets(tripId: string, linkId: string, userId: number, sid: string) {
|
||||
return syncAlbumAssets(tripId, linkId, userId, sid);
|
||||
}
|
||||
|
||||
// ── Synology ────────────────────────────────────────────────────────────────
|
||||
synologyGetSettings(userId: number) {
|
||||
return getSynologySettings(userId);
|
||||
}
|
||||
|
||||
synologyUpdateSettings(userId: number, url: string, username: string, password: string, skipSsl: boolean) {
|
||||
return updateSynologySettings(userId, url, username, password, skipSsl);
|
||||
}
|
||||
|
||||
synologyGetStatus(userId: number) {
|
||||
return getSynologyStatus(userId);
|
||||
}
|
||||
|
||||
synologyTestConnection(userId: number, url: string, username: string, password: string, otp: string, skipSsl: boolean) {
|
||||
return testSynologyConnection(userId, url, username, password, otp, skipSsl);
|
||||
}
|
||||
|
||||
synologyListAlbums(userId: number) {
|
||||
return listSynologyAlbums(userId);
|
||||
}
|
||||
|
||||
synologyGetAlbumPhotos(userId: number, albumId: string, passphrase?: string) {
|
||||
return getSynologyAlbumPhotos(userId, albumId, passphrase);
|
||||
}
|
||||
|
||||
synologySyncAlbumLink(userId: number, tripId: string, linkId: string, sid: string) {
|
||||
return syncSynologyAlbumLink(userId, tripId, linkId, sid);
|
||||
}
|
||||
|
||||
synologySearchPhotos(userId: number, from: string | undefined, to: string | undefined, offset: number, limit: number) {
|
||||
return searchSynologyPhotos(userId, from, to, offset, limit);
|
||||
}
|
||||
|
||||
synologyGetAssetInfo(userId: number, photoId: string, ownerId: number, passphrase?: string) {
|
||||
return getSynologyAssetInfo(userId, photoId, ownerId, passphrase);
|
||||
}
|
||||
|
||||
synologyStreamAsset(res: Response, userId: number, ownerId: number, photoId: string, kind: 'thumbnail' | 'original', size: string, passphrase?: string) {
|
||||
return streamSynologyAsset(res, userId, ownerId, photoId, kind, size, passphrase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { User } from '../../types';
|
||||
import type { ServiceResult } from '../../services/memories/helpersService';
|
||||
import { fail, success } from '../../services/memories/helpersService';
|
||||
import { MemoriesService } from './memories.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
function _parseStringBodyField(value: unknown): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function _parseNumberBodyField(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/integrations/memories/synologyphotos — Synology Photos connection,
|
||||
* search, albums and asset proxy.
|
||||
*
|
||||
* Byte-identical to the legacy Express router (server/src/routes/memories/synology.ts):
|
||||
* every response goes through the service `ServiceResult` envelope (success →
|
||||
* `res.json(data)` at 200, error → status + `{ error }`); `/status` and `/test`
|
||||
* always answer 200 (the service shapes `{ connected: false, error }` on
|
||||
* failure); the asset routes use the distinct 403 string "You don't have access
|
||||
* to this photo"; `/info` is declared before the catch-all `/:kind` so the
|
||||
* literal route wins as Express ordered it; lenient hand-rolled coercion is kept.
|
||||
*/
|
||||
@Controller('api/integrations/memories/synologyphotos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SynologyMemoriesController {
|
||||
constructor(private readonly memories: MemoriesService) {}
|
||||
|
||||
private handle<T>(res: Response, result: ServiceResult<T>): void {
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
} else {
|
||||
res.json(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
async getSettings(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
|
||||
this.handle(res, await this.memories.synologyGetSettings(user.id));
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
async putSettings(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
|
||||
const synology_url = _parseStringBodyField(body.synology_url);
|
||||
const synology_username = _parseStringBodyField(body.synology_username);
|
||||
const synology_password = _parseStringBodyField(body.synology_password);
|
||||
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
|
||||
|
||||
if (!synology_url || !synology_username) {
|
||||
this.handle(res, fail('URL and username are required', 400));
|
||||
} else {
|
||||
this.handle(res, await this.memories.synologyUpdateSettings(user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
|
||||
}
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
async getStatus(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
|
||||
this.handle(res, await this.memories.synologyGetStatus(user.id));
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@HttpCode(200)
|
||||
async test(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
|
||||
const synology_url = _parseStringBodyField(body.synology_url);
|
||||
const synology_username = _parseStringBodyField(body.synology_username);
|
||||
const synology_password = _parseStringBodyField(body.synology_password);
|
||||
const synology_otp = _parseStringBodyField(body.synology_otp);
|
||||
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
|
||||
|
||||
if (!synology_url || !synology_username || !synology_password) {
|
||||
const missingFields: string[] = [];
|
||||
if (!synology_url) missingFields.push('URL');
|
||||
if (!synology_username) missingFields.push('Username');
|
||||
if (!synology_password) missingFields.push('Password');
|
||||
this.handle(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
|
||||
} else {
|
||||
this.handle(res, await this.memories.synologyTestConnection(user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
|
||||
}
|
||||
}
|
||||
|
||||
@Get('albums')
|
||||
async albums(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
|
||||
this.handle(res, await this.memories.synologyListAlbums(user.id));
|
||||
}
|
||||
|
||||
@Get('albums/:albumId/photos')
|
||||
async albumPhotos(@CurrentUser() user: User, @Param('albumId') albumId: string, @Query('passphrase') passphraseRaw: string | undefined, @Res() res: Response): Promise<void> {
|
||||
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
|
||||
this.handle(res, await this.memories.synologyGetAlbumPhotos(user.id, albumId, passphrase));
|
||||
}
|
||||
|
||||
@Post('trips/:tripId/album-links/:linkId/sync')
|
||||
@HttpCode(200)
|
||||
async sync(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('linkId') linkId: string,
|
||||
@Headers('x-socket-id') sid: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.handle(res, await this.memories.synologySyncAlbumLink(user.id, tripId, linkId, sid));
|
||||
}
|
||||
|
||||
@Post('search')
|
||||
@HttpCode(200)
|
||||
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
|
||||
const from = _parseStringBodyField(body.from);
|
||||
const to = _parseStringBodyField(body.to);
|
||||
let offset = _parseNumberBodyField(body.offset, 0);
|
||||
const page = _parseNumberBodyField(body.page, 1) - 1;
|
||||
let limit = _parseNumberBodyField(body.limit, 100);
|
||||
const size = _parseNumberBodyField(body.size, 0);
|
||||
if (size > 0) limit = size;
|
||||
if (page > 0) offset = page * limit;
|
||||
|
||||
this.handle(res, await this.memories.synologySearchPhotos(user.id, from || undefined, to || undefined, offset, limit));
|
||||
}
|
||||
|
||||
@Get('assets/:tripId/:photoId/:ownerId/info')
|
||||
async assetInfo(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('photoId') photoId: string,
|
||||
@Param('ownerId') ownerId: string,
|
||||
@Query('passphrase') passphraseRaw: string | undefined,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
|
||||
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||
this.handle(res, fail("You don't have access to this photo", 403));
|
||||
} else {
|
||||
this.handle(res, await this.memories.synologyGetAssetInfo(user.id, photoId, Number(ownerId), passphrase));
|
||||
}
|
||||
}
|
||||
|
||||
@Get('assets/:tripId/:photoId/:ownerId/:kind')
|
||||
async asset(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('photoId') photoId: string,
|
||||
@Param('ownerId') ownerId: string,
|
||||
@Param('kind') kind: string,
|
||||
@Query('size') sizeRaw: string | undefined,
|
||||
@Query('passphrase') passphraseRaw: string | undefined,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
|
||||
const rawSize = String(sizeRaw ?? 'sm');
|
||||
const size = (VALID_SIZES as readonly string[]).includes(rawSize) ? rawSize : 'sm';
|
||||
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
|
||||
|
||||
if (kind !== 'thumbnail' && kind !== 'original') {
|
||||
this.handle(res, fail('Invalid asset kind', 400));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||
this.handle(res, fail("You don't have access to this photo", 403));
|
||||
} else {
|
||||
await this.memories.synologyStreamAsset(res, user.id, Number(ownerId), photoId, kind, String(size), passphrase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Post, Put, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { User } from '../../types';
|
||||
import type { Selection } from '../../services/memories/helpersService';
|
||||
import { MemoriesService } from './memories.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* /api/integrations/memories/unified — provider-agnostic trip photo + album-link
|
||||
* management.
|
||||
*
|
||||
* Byte-identical to the legacy Express router (server/src/routes/memories/unified.ts):
|
||||
* bare `authenticate` (JwtAuthGuard), success bodies on 200, and the per-result
|
||||
* error envelope `{ error }` at `result.error.status` reused from the unified
|
||||
* service. Lenient hand-rolled body coercion is preserved — no Zod here.
|
||||
*/
|
||||
@Controller('api/integrations/memories/unified')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UnifiedMemoriesController {
|
||||
constructor(private readonly memories: MemoriesService) {}
|
||||
|
||||
@Get('trips/:tripId/photos')
|
||||
listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
|
||||
const result = this.memories.listTripPhotos(tripId, user.id);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
}
|
||||
res.json({ photos: result.data });
|
||||
}
|
||||
|
||||
@Post('trips/:tripId/photos')
|
||||
@HttpCode(200)
|
||||
async addPhotos(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Headers('x-socket-id') sid: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const selections: Selection[] = Array.isArray(body?.selections) ? (body.selections as Selection[]) : [];
|
||||
const shared = body?.shared === undefined ? true : !!body?.shared;
|
||||
const result = await this.memories.addTripPhotos(tripId, user.id, shared, selections, sid);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, added: result.data.added });
|
||||
}
|
||||
|
||||
@Put('trips/:tripId/photos/sharing')
|
||||
async setSharing(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = await this.memories.setTripPhotoSharing(tripId, user.id, Number(body?.photo_id), body?.shared as boolean);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Delete('trips/:tripId/photos')
|
||||
async removePhoto(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const result = this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id));
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Get('trips/:tripId/album-links')
|
||||
listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
|
||||
const result = this.memories.listTripAlbumLinks(tripId, user.id);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
}
|
||||
res.json({ links: result.data });
|
||||
}
|
||||
|
||||
@Post('trips/:tripId/album-links')
|
||||
@HttpCode(200)
|
||||
createAlbumLink(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@Res() res: Response,
|
||||
): void {
|
||||
const passphrase = body?.passphrase ? String(body.passphrase) : undefined;
|
||||
const result = this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
@Delete('trips/:tripId/album-links/:linkId')
|
||||
removeAlbumLink(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('linkId') linkId: string,
|
||||
@Res() res: Response,
|
||||
): void {
|
||||
const result = this.memories.removeAlbumLink(tripId, linkId, user.id);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -2,62 +2,39 @@ import express, { Request, Response, NextFunction } from 'express';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import multer from 'multer';
|
||||
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
||||
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
|
||||
import { db } from './db/database';
|
||||
|
||||
import authRoutes from './routes/auth';
|
||||
import tripsRoutes from './routes/trips';
|
||||
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
|
||||
import placesRoutes from './routes/places';
|
||||
import assignmentsRoutes from './routes/assignments';
|
||||
import packingRoutes from './routes/packing';
|
||||
import todoRoutes from './routes/todo';
|
||||
import tagsRoutes from './routes/tags';
|
||||
import categoriesRoutes from './routes/categories';
|
||||
import adminRoutes from './routes/admin';
|
||||
import mapsRoutes from './routes/maps';
|
||||
import airportsRoutes from './routes/airports';
|
||||
import filesRoutes from './routes/files';
|
||||
import reservationsRoutes from './routes/reservations';
|
||||
import dayNotesRoutes from './routes/dayNotes';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import budgetRoutes from './routes/budget';
|
||||
import collabRoutes from './routes/collab';
|
||||
import backupRoutes from './routes/backup';
|
||||
import oidcRoutes from './routes/oidc';
|
||||
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
|
||||
import vacayRoutes from './routes/vacay';
|
||||
import atlasRoutes from './routes/atlas';
|
||||
import memoriesRoutes from './routes/memories/unified';
|
||||
import photoRoutes from './routes/photos';
|
||||
import notificationRoutes from './routes/notifications';
|
||||
import shareRoutes from './routes/share';
|
||||
import journeyRoutes from './routes/journey';
|
||||
import journeyPublicRoutes from './routes/journeyPublic';
|
||||
import publicConfigRoutes from './routes/publicConfig';
|
||||
import systemNoticesRoutes from './routes/systemNotices';
|
||||
import { mcpHandler } from './mcp';
|
||||
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
|
||||
import { Addon, AuthRequest } from './types';
|
||||
import { getUpcomingReservations } from './services/reservationService';
|
||||
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||
import { getCollabFeatures } from './services/adminService';
|
||||
import { isAddonEnabled } from './services/adminService';
|
||||
import { ADDON_IDS } from './addons';
|
||||
import { ALL_SCOPES } from './mcp/scopes';
|
||||
import { verifyJwtAndLoadUser } from '../../middleware/auth';
|
||||
import { db } from '../../db/database';
|
||||
import { mcpHandler } from '../../mcp';
|
||||
import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { ALL_SCOPES } from '../../mcp/scopes';
|
||||
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
||||
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
|
||||
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
|
||||
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
|
||||
import { getMcpSafeUrl } from './services/notifications';
|
||||
import { getMcpSafeUrl } from '../../services/notifications';
|
||||
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
|
||||
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
|
||||
//
|
||||
// IMPORTANT — path resolution: the original blocks lived in src/app.ts, where __dirname
|
||||
// resolves to the directory of app.js (one level above the uploads/public anchor), so they
|
||||
// used '../uploads/...' and '../public'. This file lives three levels deeper
|
||||
// (src/nest/platform/), so __dirname is three levels deeper too. The relative prefixes are
|
||||
// therefore '../../../uploads/...' and '../../../public' — which resolve to the EXACT same
|
||||
// absolute paths as before. This is the only intentional change; everything else is byte-for-byte
|
||||
// identical. (rootDir/outDir preserve the tree, so the offset holds in both source/test and
|
||||
// compiled/dist execution — matching the other nest controllers that use '../../../uploads/...'.)
|
||||
|
||||
applyGlobalMiddleware(app);
|
||||
const UPLOADS_DIR = path.join(__dirname, '../../../uploads');
|
||||
export const PUBLIC_DIR = path.join(__dirname, '../../../public');
|
||||
|
||||
/**
|
||||
* Static + guarded /uploads/* routes. Must be applied BEFORE the API route mounts
|
||||
* (identical to its original position near the top of createApp).
|
||||
*/
|
||||
export function applyPlatformUploads(app: express.Application): void {
|
||||
// Static: avatars, covers, and journey photos.
|
||||
//
|
||||
// Security model (audit SEC-M9): these paths are unauthenticated by
|
||||
@@ -76,9 +53,9 @@ export function createApp(): express.Application {
|
||||
// not embedded in unauthenticated UI contexts, so that endpoint IS
|
||||
// gated (session JWT with pv, or a share token scoped to the photo's
|
||||
// trip).
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
|
||||
app.use('/uploads/avatars', express.static(path.join(UPLOADS_DIR, 'avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(UPLOADS_DIR, 'covers')));
|
||||
app.use('/uploads/journey', express.static(path.join(UPLOADS_DIR, 'journey')));
|
||||
|
||||
// Photos require either a valid logged-in session (via JWT with the
|
||||
// password_version gate) OR a share token that covers the SPECIFIC
|
||||
@@ -87,9 +64,9 @@ export function createApp(): express.Application {
|
||||
// unguessable, but the auth model was wrong.
|
||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
const safeName = path.basename(req.params.filename);
|
||||
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
||||
const filePath = path.join(UPLOADS_DIR, 'photos', safeName);
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
|
||||
if (!resolved.startsWith(path.resolve(UPLOADS_DIR, 'photos'))) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
// existsSync here is cheap and avoids a sendFile error frame; kept
|
||||
@@ -122,116 +99,23 @@ export function createApp(): express.Application {
|
||||
app.use('/uploads/files', (_req: Request, res: Response) => {
|
||||
res.status(401).send('Authentication required');
|
||||
});
|
||||
}
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/auth/oidc', oidcRoutes);
|
||||
app.use('/api/trips', tripsRoutes);
|
||||
app.use('/api/trips/:tripId/days', daysRoutes);
|
||||
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
|
||||
app.use('/api/trips/:tripId/places', placesRoutes);
|
||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||
app.use('/api/trips/:tripId/todo', todoRoutes);
|
||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||
app.get('/api/reservations/upcoming', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ reservations: getUpcomingReservations(authReq.user.id) });
|
||||
});
|
||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||
/**
|
||||
* Legacy /api/health handler, the OAuth/MCP SDK + transport wiring (well-known metadata,
|
||||
* authorize/register SDK handlers, the COOP header, the /mcp routes), and the production
|
||||
* SPA static + catch-all. Must be applied AFTER the API route mounts and BEFORE the global
|
||||
* error handler (identical to its original position near the bottom of createApp).
|
||||
*
|
||||
* Note: the SDK metadata closures (getOAuthMetadata/getMetaRouter) and their lazy-init
|
||||
* cache are kept module-local PER CALL so each app instance gets its own lazy state — the
|
||||
* same as when they were function-local inside createApp.
|
||||
*/
|
||||
export function applyPlatformTransport(app: express.Application): void {
|
||||
app.get('/api/health', (_req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-store, must-revalidate')
|
||||
res.json({ status: 'ok' })
|
||||
});
|
||||
app.use('/api/config', publicConfigRoutes);
|
||||
app.use('/api', assignmentsRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Addons list endpoint
|
||||
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
|
||||
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||
const providers = db.prepare(`
|
||||
SELECT id, name, icon, enabled, sort_order
|
||||
FROM photo_providers
|
||||
WHERE enabled = 1
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
|
||||
const fields = db.prepare(`
|
||||
SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
hint?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order: number;
|
||||
}>;
|
||||
|
||||
const fieldsByProvider = new Map<string, typeof fields>();
|
||||
for (const field of fields) {
|
||||
const arr = fieldsByProvider.get(field.provider_id) || [];
|
||||
arr.push(field);
|
||||
fieldsByProvider.set(field.provider_id, arr);
|
||||
}
|
||||
|
||||
res.json({
|
||||
collabFeatures: getCollabFeatures(),
|
||||
addons: [
|
||||
...addons.map(a => ({ ...a, enabled: !!a.enabled })),
|
||||
...providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: getPhotoProviderConfig(p.id),
|
||||
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
placeholder: f.placeholder || '',
|
||||
hint: f.hint || null,
|
||||
required: !!f.required,
|
||||
secret: !!f.secret,
|
||||
settings_key: f.settings_key || null,
|
||||
payload_key: f.payload_key || null,
|
||||
sort_order: f.sort_order,
|
||||
})),
|
||||
})),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Addon routes
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
app.use('/api/journeys', (req, res, next) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
|
||||
next();
|
||||
}, journeyRoutes);
|
||||
app.use('/api/public/journey', journeyPublicRoutes);
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
app.use('/api/photos', photoRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/airports', airportsRoutes);
|
||||
// /api/weather is served by the NestJS weather module (see src/nest/weather);
|
||||
// the legacy Express route was decommissioned after the migration (L1).
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/system-notices', systemNoticesRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api', shareRoutes);
|
||||
|
||||
// OAuth 2.1 — public endpoints
|
||||
// Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting)
|
||||
@@ -240,10 +124,6 @@ export function createApp(): express.Application {
|
||||
next();
|
||||
};
|
||||
|
||||
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
|
||||
// Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate
|
||||
app.use('/api/oauth', oauthApiRouter);
|
||||
|
||||
// SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB)
|
||||
// is not called at createApp() time, before test tables have been created.
|
||||
// mcpAuthMetadataRouter serves:
|
||||
@@ -326,10 +206,6 @@ export function createApp(): express.Application {
|
||||
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
|
||||
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
|
||||
|
||||
// Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth)
|
||||
// oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here
|
||||
app.use('/', oauthPublicRouter);
|
||||
|
||||
// MCP endpoint
|
||||
app.post('/mcp', mcpHandler);
|
||||
app.get('/mcp', mcpHandler);
|
||||
@@ -349,39 +225,43 @@ export function createApp(): express.Application {
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Production static file serving
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const publicPath = path.join(__dirname, '../public');
|
||||
app.use(express.static(publicPath, {
|
||||
/**
|
||||
* Production SPA serving: the built client static assets + the index.html catch-all
|
||||
* for client-side routes. This is the LEGACY (plain Express 4) form — a real
|
||||
* `app.get(catch-all)` registered as the terminal handler. The NestJS bootstrap can
|
||||
* NOT use this (its router terminates unmatched requests with a 404 before any
|
||||
* post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves
|
||||
* the SPA via the SpaFallbackFilter instead. Both produce the identical result:
|
||||
* unmatched GET → index.html in production.
|
||||
*/
|
||||
export function applyPlatformSpa(app: express.Application): void {
|
||||
applyPlatformStatic(app);
|
||||
if (process.env.NODE_ENV !== 'production') return;
|
||||
// /.*/ rather than '*' so the helper is Express-4 and Express-5 safe.
|
||||
app.get(/.*/, (_req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Production static serving of the built client (JS/CSS/assets). Split out from
|
||||
* applyPlatformSpa because the NestJS bootstrap needs the static files served
|
||||
* BEFORE its router (so a real asset request returns the file, not the SPA
|
||||
* index.html), while the index.html catch-all is handled separately (legacy:
|
||||
* app.get catch-all; Nest: SpaFallbackFilter). No-op outside production.
|
||||
*/
|
||||
export function applyPlatformStatic(app: express.Application): void {
|
||||
if (process.env.NODE_ENV !== 'production') return;
|
||||
app.use(
|
||||
express.static(PUBLIC_DIR, {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
},
|
||||
}));
|
||||
app.get('*', (_req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(publicPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
app.use((err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('Unhandled error:', err.message);
|
||||
} else {
|
||||
console.error('Unhandled error:', err);
|
||||
}
|
||||
if (err instanceof multer.MulterError) {
|
||||
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||
return res.status(status).json({ error: err.message });
|
||||
}
|
||||
const status = err.statusCode || err.status || 500;
|
||||
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
||||
const message = status < 500 ? err.message : 'Internal server error';
|
||||
res.status(status).json({ error: message });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'node:path';
|
||||
import { PUBLIC_DIR } from './platform.routes';
|
||||
|
||||
/**
|
||||
* Serves the built SPA (index.html) for any request the NestJS router did not
|
||||
* match — the production single-page-app fallback. This replaces the legacy
|
||||
* Express `app.get('*')` catch-all, which cannot run on the Nest instance: Nest's
|
||||
* router terminates an unmatched request by throwing NotFoundException (it never
|
||||
* falls through to a post-init Express route), so the SPA fallback has to live
|
||||
* inside the Nest pipeline as a NotFound filter instead.
|
||||
*
|
||||
* Behaviour matches the legacy catch-all exactly: in production, an unmatched GET
|
||||
* returns index.html; everything else (non-GET, or dev where there is no built
|
||||
* client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)`
|
||||
* is more specific than the global TrekExceptionFilter, so Nest routes 404s here
|
||||
* while every other error still flows through TrekExceptionFilter.
|
||||
*/
|
||||
@Catch(NotFoundException)
|
||||
export class SpaFallbackFilter implements ExceptionFilter {
|
||||
catch(exception: NotFoundException, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const req = ctx.getRequest<Request>();
|
||||
const res = ctx.getResponse<Response>();
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && req.method === 'GET') {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-production, or a non-GET miss: keep the standard TREK 404 envelope
|
||||
// (identical to what TrekExceptionFilter produces for a NotFoundException).
|
||||
res.status(404).json({ error: exception.message || 'Not Found' });
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@ import { ReservationsController } from './reservations.controller';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { AccommodationsController } from './accommodations.controller';
|
||||
import { AccommodationsService } from './accommodations.service';
|
||||
import { UpcomingReservationsController } from './upcoming-reservations.controller';
|
||||
|
||||
/**
|
||||
* Reservations + accommodations domain (S5 — Phase 2 trip sub-domain).
|
||||
* Two mounts: /api/trips/:tripId/reservations and /accommodations.
|
||||
* Mounts: /api/trips/:tripId/reservations, /accommodations, and the cross-trip
|
||||
* /api/reservations/upcoming dashboard feed.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ReservationsController, AccommodationsController],
|
||||
controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController],
|
||||
providers: [ReservationsService, AccommodationsService],
|
||||
})
|
||||
export class ReservationsModule {}
|
||||
|
||||
@@ -34,6 +34,12 @@ export class ReservationsService {
|
||||
return svc.listReservations(tripId);
|
||||
}
|
||||
|
||||
// Cross-trip "upcoming reservations" feed (dashboard widget). Reuses the legacy
|
||||
// query unchanged; the default limit (6) matches the legacy inline handler.
|
||||
listUpcoming(userId: number) {
|
||||
return svc.getUpcomingReservations(userId);
|
||||
}
|
||||
|
||||
create(tripId: string, data: Parameters<typeof svc.createReservation>[1]) {
|
||||
return svc.createReservation(tripId, data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
/**
|
||||
* GET /api/reservations/upcoming — the cross-trip "upcoming reservations" feed
|
||||
* (dashboard widget). Byte-identical to the legacy inline handler in
|
||||
* server/src/app.ts (authenticate, returns { reservations: [...] }, limit 6).
|
||||
*
|
||||
* Separate from the trip-scoped ReservationsController
|
||||
* (/api/trips/:tripId/reservations) because the base path differs.
|
||||
*/
|
||||
@Controller('api/reservations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UpcomingReservationsController {
|
||||
constructor(private readonly reservations: ReservationsService) {}
|
||||
|
||||
@Get('upcoming')
|
||||
upcoming(@CurrentUser() user: User) {
|
||||
return { reservations: this.reservations.listUpcoming(user.id) };
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Strangler toggle for the incremental NestJS migration.
|
||||
*
|
||||
* `getNestPrefixes()` returns the request path prefixes that NestJS handles;
|
||||
* every other path falls through to the legacy Express app. The default is the
|
||||
* set of prefixes whose Nest modules exist. Operators can override it at runtime
|
||||
* via the `NEST_PREFIXES` env var (comma-separated) for instant Nest<->Express
|
||||
* rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
|
||||
* everything back to the legacy app.
|
||||
*/
|
||||
const DEFAULT_NEST_PREFIXES = [
|
||||
'/api/_nest',
|
||||
'/api/weather',
|
||||
'/api/airports',
|
||||
'/api/config',
|
||||
'/api/system-notices',
|
||||
'/api/maps',
|
||||
'/api/categories',
|
||||
'/api/tags',
|
||||
'/api/notifications',
|
||||
'/api/addons/atlas',
|
||||
'/api/addons/vacay',
|
||||
'/api/trips/:tripId/packing',
|
||||
'/api/trips/:tripId/todo',
|
||||
'/api/trips/:tripId/budget',
|
||||
'/api/trips/:tripId/reservations',
|
||||
'/api/trips/:tripId/accommodations',
|
||||
'/api/trips/:tripId/days',
|
||||
'/api/trips/:tripId/assignments',
|
||||
'/api/trips/:tripId/places',
|
||||
'/api/trips/:tripId/collab',
|
||||
'/api/trips/:tripId/files',
|
||||
'/api/photos',
|
||||
'/api/journeys',
|
||||
'/api/public/journey',
|
||||
'/api/shared',
|
||||
'/api/settings',
|
||||
'/api/backup',
|
||||
// Auth — listed as explicit sub-paths (rather than one broad /api/auth prefix)
|
||||
// so each endpoint was flipped to Nest individually as it was migrated. All
|
||||
// current /api/auth/* endpoints below, including /api/auth/oidc, are handled
|
||||
// by Nest; nothing here falls through to Express anymore.
|
||||
'/api/auth/app-config',
|
||||
'/api/auth/demo-login',
|
||||
'/api/auth/invite',
|
||||
'/api/auth/register',
|
||||
'/api/auth/login',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/me',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/avatar',
|
||||
'/api/auth/users',
|
||||
'/api/auth/validate-keys',
|
||||
'/api/auth/app-settings',
|
||||
'/api/auth/travel-stats',
|
||||
'/api/auth/mfa',
|
||||
'/api/auth/mcp-tokens',
|
||||
'/api/auth/ws-token',
|
||||
'/api/auth/resource-token',
|
||||
'/api/auth/oidc',
|
||||
'/api/oauth',
|
||||
// OAuth public endpoints — explicit so the SDK-mounted /oauth/authorize,
|
||||
// /oauth/register and /oauth/consent keep falling through to Express.
|
||||
'/oauth/token',
|
||||
'/oauth/userinfo',
|
||||
'/oauth/revoke',
|
||||
'/api/admin',
|
||||
'/api/trips/:tripId/share-link',
|
||||
'/api/trips|',
|
||||
'/api/trips/:tripId|',
|
||||
'/api/trips/:tripId/members',
|
||||
'/api/trips/:tripId/cover',
|
||||
'/api/trips/:tripId/copy',
|
||||
'/api/trips/:tripId/bundle',
|
||||
'/api/trips/:tripId/export.ics',
|
||||
];
|
||||
|
||||
export function getNestPrefixes(): string[] {
|
||||
const raw = process.env.NEST_PREFIXES;
|
||||
if (raw !== undefined) {
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return DEFAULT_NEST_PREFIXES;
|
||||
}
|
||||
|
||||
function escapeRegExp(segment: string): string {
|
||||
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one prefix into a matcher.
|
||||
*
|
||||
* - A static prefix (no `:param`) uses a plain exact/sub-path match.
|
||||
* - A pattern prefix containing `:param` segments — needed for trip-scoped
|
||||
* routes like `/api/trips/:tripId/packing`, where the legacy mount sits
|
||||
* between dynamic ids — compiles to a regex in which each `:param` matches
|
||||
* exactly one path segment, so a single nested mount routes to Nest without
|
||||
* capturing sibling routes (days, places, ...) still served by Express.
|
||||
* - A trailing `|` marks the prefix as EXACT — it matches that path only, NOT
|
||||
* its sub-paths. This is what lets an aggregate-root like `/api/trips` migrate
|
||||
* (its own /api/trips and /api/trips/:id routes) without swallowing the
|
||||
* not-yet-migrated nested mounts (/api/trips/:id/collab, /files, ...).
|
||||
*/
|
||||
function prefixToMatcher(prefix: string): (path: string) => boolean {
|
||||
const exact = prefix.endsWith('|');
|
||||
const p = exact ? prefix.slice(0, -1) : prefix;
|
||||
|
||||
if (!p.includes(':')) {
|
||||
if (exact) return (path) => path === p;
|
||||
return (path) => path === p || path.startsWith(p + '/');
|
||||
}
|
||||
|
||||
const pattern = p
|
||||
.split('/')
|
||||
.map((segment) => (segment.startsWith(':') ? '[^/]+' : escapeRegExp(segment)))
|
||||
.join('/');
|
||||
const re = new RegExp(exact ? `^${pattern}$` : `^${pattern}(?:/.*)?$`);
|
||||
return (path) => re.test(path);
|
||||
}
|
||||
|
||||
/** Builds a matcher: true when `path` belongs to one of the migrated prefixes. */
|
||||
export function makeNestPathMatcher(prefixes: string[]): (path: string) => boolean {
|
||||
const matchers = prefixes.map(prefixToMatcher);
|
||||
return (path) => matchers.some((matches) => matches(path));
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||
import * as svc from '../services/adminService';
|
||||
import { getAdminUserDefaults, setAdminUserDefaults } from '../services/settingsService';
|
||||
import { invalidateMcpSessions } from '../mcp';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate, adminOnly);
|
||||
|
||||
// ── User CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/users', (_req: Request, res: Response) => {
|
||||
res.json({ users: svc.listUsers() });
|
||||
});
|
||||
|
||||
router.post('/users', (req: Request, res: Response) => {
|
||||
const result = svc.createUser(req.body);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_create',
|
||||
resource: String(result.insertedId),
|
||||
ip: getClientIp(req),
|
||||
details: result.auditDetails,
|
||||
});
|
||||
res.status(201).json({ user: result.user });
|
||||
});
|
||||
|
||||
router.put('/users/:id', (req: Request, res: Response) => {
|
||||
const result = svc.updateUser(req.params.id, req.body);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_update',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { targetUser: result.previousEmail, fields: result.changed },
|
||||
});
|
||||
logInfo(`Admin ${authReq.user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
|
||||
res.json({ user: result.user });
|
||||
});
|
||||
|
||||
router.delete('/users/:id', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.deleteUser(req.params.id, authReq.user.id);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.user_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { targetUser: result.email },
|
||||
});
|
||||
logInfo(`Admin ${authReq.user.email} deleted user ${result.email}`);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/stats', (_req: Request, res: Response) => {
|
||||
res.json(svc.getStats());
|
||||
});
|
||||
|
||||
// ── Permissions ────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/permissions', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPermissions());
|
||||
});
|
||||
|
||||
router.put('/permissions', (req: Request, res: Response) => {
|
||||
const { permissions } = req.body;
|
||||
if (!permissions || typeof permissions !== 'object') {
|
||||
return res.status(400).json({ error: 'permissions object required' });
|
||||
}
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.savePermissions(permissions);
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.permissions_update',
|
||||
resource: 'permissions',
|
||||
ip: getClientIp(req),
|
||||
details: permissions,
|
||||
});
|
||||
res.json({ success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) });
|
||||
});
|
||||
|
||||
// ── Audit Log ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/audit-log', (req: Request, res: Response) => {
|
||||
res.json(svc.getAuditLog(req.query as { limit?: string; offset?: string }));
|
||||
});
|
||||
|
||||
// ── OIDC Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/oidc', (_req: Request, res: Response) => {
|
||||
res.json(svc.getOidcSettings());
|
||||
});
|
||||
|
||||
router.put('/oidc', (req: Request, res: Response) => {
|
||||
const result = svc.updateOidcSettings(req.body);
|
||||
if (result.error) {
|
||||
return res.status(result.status || 400).json({ error: result.error });
|
||||
}
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.oidc_update',
|
||||
ip: getClientIp(req),
|
||||
details: { issuer_set: !!req.body.issuer },
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/save-demo-baseline', (req: Request, res: Response) => {
|
||||
const result = svc.saveDemoBaseline();
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||
res.json({ success: true, message: result.message });
|
||||
});
|
||||
|
||||
// ── GitHub / Version ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/github-releases', async (req: Request, res: Response) => {
|
||||
const { per_page = '10', page = '1' } = req.query;
|
||||
res.json(await svc.getGithubReleases(String(per_page), String(page)));
|
||||
});
|
||||
|
||||
router.get('/version-check', async (_req: Request, res: Response) => {
|
||||
res.json(await svc.checkVersion());
|
||||
});
|
||||
|
||||
// ── Admin notification preferences ────────────────────────────────────────
|
||||
|
||||
router.get('/notification-preferences', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
|
||||
});
|
||||
|
||||
router.put('/notification-preferences', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
setAdminPreferences(authReq.user.id, req.body);
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin'));
|
||||
});
|
||||
|
||||
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/invites', (_req: Request, res: Response) => {
|
||||
res.json({ invites: svc.listInvites() });
|
||||
});
|
||||
|
||||
router.post('/invites', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.createInvite(authReq.user.id, req.body);
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.invite_create',
|
||||
resource: String(result.inviteId),
|
||||
ip: getClientIp(req),
|
||||
details: { max_uses: result.uses, expires_in_days: result.expiresInDays },
|
||||
});
|
||||
res.status(201).json({ invite: result.invite });
|
||||
});
|
||||
|
||||
router.delete('/invites/:id', (req: Request, res: Response) => {
|
||||
const result = svc.deleteInvite(req.params.id);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.invite_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Bag Tracking ───────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/bag-tracking', (_req: Request, res: Response) => {
|
||||
res.json(svc.getBagTracking());
|
||||
});
|
||||
|
||||
router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||
const result = svc.updateBagTracking(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.bag_tracking',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Photos ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-photos', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesPhotos());
|
||||
});
|
||||
|
||||
router.put('/places-photos', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesPhotos(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_photos',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Autocomplete ──────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-autocomplete', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesAutocomplete());
|
||||
});
|
||||
|
||||
router.put('/places-autocomplete', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesAutocomplete(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_autocomplete',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Details ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-details', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesDetails());
|
||||
});
|
||||
|
||||
router.put('/places-details', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesDetails(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_details',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/collab-features', (_req: Request, res: Response) => {
|
||||
res.json(svc.getCollabFeatures());
|
||||
});
|
||||
|
||||
router.put('/collab-features', (req: Request, res: Response) => {
|
||||
const result = svc.updateCollabFeatures(req.body);
|
||||
invalidateMcpSessions();
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.collab_features',
|
||||
ip: getClientIp(req),
|
||||
details: result,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Packing Templates ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/packing-templates', (_req: Request, res: Response) => {
|
||||
res.json({ templates: svc.listPackingTemplates() });
|
||||
});
|
||||
|
||||
router.get('/packing-templates/:id', (req: Request, res: Response) => {
|
||||
const result = svc.getPackingTemplate(req.params.id);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post('/packing-templates', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.createPackingTemplate(req.body.name, authReq.user.id);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
router.put('/packing-templates/:id', (req: Request, res: Response) => {
|
||||
const result = svc.updatePackingTemplate(req.params.id, req.body);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
|
||||
const result = svc.deletePackingTemplate(req.params.id);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.packing_template_delete',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: { name: result.name },
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Template categories
|
||||
|
||||
router.post('/packing-templates/:id/categories', (req: Request, res: Response) => {
|
||||
const result = svc.createTemplateCategory(req.params.id, req.body.name);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
|
||||
const result = svc.updateTemplateCategory(req.params.templateId, req.params.catId, req.body);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
|
||||
const result = svc.deleteTemplateCategory(req.params.templateId, req.params.catId);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Template items
|
||||
|
||||
router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => {
|
||||
const result = svc.createTemplateItem(req.params.templateId, req.params.catId, req.body.name);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
|
||||
const result = svc.updateTemplateItem(req.params.itemId, req.body);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
|
||||
const result = svc.deleteTemplateItem(req.params.itemId);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Addons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/addons', (_req: Request, res: Response) => {
|
||||
res.json({ addons: svc.listAddons() });
|
||||
});
|
||||
|
||||
router.put('/addons/:id', (req: Request, res: Response) => {
|
||||
const result = svc.updateAddon(req.params.id, req.body);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.addon_update',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: result.auditDetails,
|
||||
});
|
||||
// Invalidate all MCP sessions so they re-create with the updated addon tool set
|
||||
invalidateMcpSessions();
|
||||
res.json({ addon: result.addon });
|
||||
});
|
||||
|
||||
// ── MCP Tokens ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/mcp-tokens', (_req: Request, res: Response) => {
|
||||
res.json({ tokens: svc.listMcpTokens() });
|
||||
});
|
||||
|
||||
router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
|
||||
const result = svc.deleteMcpToken(req.params.id);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── OAuth Sessions ─────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/oauth-sessions', (_req: Request, res: Response) => {
|
||||
res.json({ sessions: svc.listOAuthSessions() });
|
||||
});
|
||||
|
||||
router.delete('/oauth-sessions/:id', (req: Request, res: Response) => {
|
||||
const result = svc.revokeOAuthSession(req.params.id);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.oauth_session.revoke',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
||||
const result = svc.rotateJwtSecret();
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.rotate_jwt_secret',
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Default User Settings ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/default-user-settings', (_req: Request, res: Response) => {
|
||||
res.json(getAdminUserDefaults());
|
||||
});
|
||||
|
||||
router.put('/default-user-settings', (req: Request, res: Response) => {
|
||||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return res.status(400).json({ error: 'Object body required' });
|
||||
}
|
||||
try {
|
||||
setAdminUserDefaults(req.body);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.default_user_settings_update',
|
||||
ip: getClientIp(req),
|
||||
details: req.body,
|
||||
});
|
||||
res.json(getAdminUserDefaults());
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
||||
if (process.env.NODE_ENV?.toLowerCase() === 'development') {
|
||||
const { send } = require('../services/notificationService');
|
||||
|
||||
router.post('/dev/test-notification', async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { event = 'trip_reminder', scope = 'user', targetId, params = {}, inApp } = req.body;
|
||||
|
||||
try {
|
||||
await send({
|
||||
event,
|
||||
actorId: authReq.user.id,
|
||||
scope,
|
||||
targetId: targetId ?? authReq.user.id,
|
||||
params: { actor: authReq.user.email, ...params },
|
||||
inApp,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -1,19 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { searchAirports, findByIata } from '../services/airportService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/search', authenticate, (req: Request, res: Response) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
||||
if (!q) return res.json([]);
|
||||
res.json(searchAirports(q));
|
||||
});
|
||||
|
||||
router.get('/:iata', authenticate, (req: Request, res: Response) => {
|
||||
const airport = findByIata(req.params.iata);
|
||||
if (!airport) return res.status(404).json({ error: 'Airport not found' });
|
||||
res.json(airport);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,137 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import {
|
||||
getAssignmentWithPlace,
|
||||
listDayAssignments,
|
||||
dayExists,
|
||||
placeExists,
|
||||
createAssignment,
|
||||
assignmentExistsInDay,
|
||||
deleteAssignment,
|
||||
reorderAssignments,
|
||||
getAssignmentForTrip,
|
||||
moveAssignment,
|
||||
getParticipants,
|
||||
updateTime,
|
||||
setParticipants,
|
||||
} from '../services/assignmentService';
|
||||
import { onPlaceCreated } from '../services/journeyService';
|
||||
import { AuthRequest } from '../types';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId, dayId } = req.params;
|
||||
|
||||
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||
|
||||
const result = listDayAssignments(dayId);
|
||||
res.json({ assignments: result });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, dayId } = req.params;
|
||||
const { place_id, notes } = req.body;
|
||||
|
||||
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||
if (!placeExists(place_id, tripId)) return res.status(404).json({ error: 'Place not found' });
|
||||
|
||||
const assignment = createAssignment(dayId, place_id, notes);
|
||||
res.status(201).json({ assignment });
|
||||
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string);
|
||||
try { onPlaceCreated(Number(tripId), Number(place_id)); } catch {}
|
||||
});
|
||||
|
||||
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, dayId, id } = req.params;
|
||||
|
||||
if (!assignmentExistsInDay(id, dayId, tripId)) return res.status(404).json({ error: 'Assignment not found' });
|
||||
|
||||
deleteAssignment(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, dayId } = req.params;
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||
|
||||
reorderAssignments(dayId, orderedIds);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/trips/:tripId/assignments/:id/move', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
const { new_day_id, order_index } = req.body;
|
||||
|
||||
const existing = getAssignmentForTrip(id, tripId);
|
||||
if (!existing) return res.status(404).json({ error: 'Assignment not found' });
|
||||
|
||||
if (!dayExists(new_day_id, tripId)) return res.status(404).json({ error: 'Target day not found' });
|
||||
|
||||
const oldDayId = existing.day_id;
|
||||
const { assignment: updated } = moveAssignment(id, new_day_id, order_index, oldDayId);
|
||||
res.json({ assignment: updated });
|
||||
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.get('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const participants = getParticipants(id);
|
||||
res.json({ participants });
|
||||
});
|
||||
|
||||
router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const existing = getAssignmentForTrip(id, tripId);
|
||||
if (!existing) return res.status(404).json({ error: 'Assignment not found' });
|
||||
|
||||
const { place_time, end_time } = req.body;
|
||||
const updated = updateTime(id, place_time, end_time);
|
||||
res.json({ assignment: updated });
|
||||
broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
|
||||
|
||||
const participants = setParticipants(id, user_ids);
|
||||
res.json({ participants });
|
||||
broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,105 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
getStats,
|
||||
getCountryPlaces,
|
||||
markCountryVisited,
|
||||
unmarkCountryVisited,
|
||||
markRegionVisited,
|
||||
unmarkRegionVisited,
|
||||
getVisitedRegions,
|
||||
getRegionGeo,
|
||||
listBucketList,
|
||||
createBucketItem,
|
||||
updateBucketItem,
|
||||
deleteBucketItem,
|
||||
} from '../services/atlasService';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/stats', async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const data = await getStats(userId);
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.get('/regions', async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
const data = await getVisitedRegions(userId);
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.get('/regions/geo', async (req: Request, res: Response) => {
|
||||
const countries = (req.query.countries as string || '').split(',').filter(Boolean);
|
||||
if (countries.length === 0) return res.json({ type: 'FeatureCollection', features: [] });
|
||||
const geo = await getRegionGeo(countries);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.json(geo);
|
||||
});
|
||||
|
||||
router.get('/country/:code', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const code = req.params.code.toUpperCase();
|
||||
res.json(getCountryPlaces(userId, code));
|
||||
});
|
||||
|
||||
router.post('/country/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
markCountryVisited(userId, req.params.code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
unmarkCountryVisited(userId, req.params.code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/region/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const { name, country_code } = req.body;
|
||||
if (!name || !country_code) return res.status(400).json({ error: 'name and country_code are required' });
|
||||
markRegionVisited(userId, req.params.code.toUpperCase(), name, country_code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/region/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
unmarkRegionVisited(userId, req.params.code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Bucket List ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/bucket-list', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
res.json({ items: listBucketList(userId) });
|
||||
});
|
||||
|
||||
router.post('/bucket-list', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const { name, lat, lng, country_code, notes, target_date } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||
const item = createBucketItem(userId, { name, lat, lng, country_code, notes, target_date });
|
||||
res.status(201).json({ item });
|
||||
});
|
||||
|
||||
router.put('/bucket-list/:id', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const { name, notes, lat, lng, country_code, target_date } = req.body;
|
||||
const item = updateBucketItem(userId, req.params.id, { name, notes, lat, lng, country_code, target_date });
|
||||
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||
res.json({ item });
|
||||
});
|
||||
|
||||
router.delete('/bucket-list/:id', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const deleted = deleteBucketItem(userId, req.params.id);
|
||||
if (!deleted) return res.status(404).json({ error: 'Item not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,413 +0,0 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth';
|
||||
import { AuthRequest, OptionalAuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { setAuthCookie, clearAuthCookie } from '../services/cookie';
|
||||
import {
|
||||
getAppConfig,
|
||||
demoLogin,
|
||||
validateInviteToken,
|
||||
registerUser,
|
||||
loginUser,
|
||||
getCurrentUser,
|
||||
changePassword,
|
||||
deleteAccount,
|
||||
updateMapsKey,
|
||||
updateApiKeys,
|
||||
updateSettings,
|
||||
getSettings,
|
||||
saveAvatar,
|
||||
deleteAvatar,
|
||||
listUsers,
|
||||
validateKeys,
|
||||
getAppSettings,
|
||||
updateAppSettings,
|
||||
getTravelStats,
|
||||
setupMfa,
|
||||
enableMfa,
|
||||
disableMfa,
|
||||
verifyMfaLogin,
|
||||
listMcpTokens,
|
||||
createMcpToken,
|
||||
deleteMcpToken,
|
||||
createWsToken,
|
||||
createResourceToken,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
} from '../services/authService';
|
||||
import { sendPasswordResetEmail, getAppUrl } from '../services/notifications';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Avatar upload (multer config stays in route — middleware concern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
|
||||
|
||||
const avatarStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, avatarDir),
|
||||
filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)),
|
||||
});
|
||||
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
||||
const avatarUpload = multer({
|
||||
storage: avatarStorage,
|
||||
limits: { fileSize: MAX_AVATAR_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
|
||||
const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiter (middleware concern — stays in route)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
|
||||
const RATE_LIMIT_CLEANUP = 5 * 60 * 1000;
|
||||
|
||||
const loginAttempts = new Map<string, { count: number; first: number }>();
|
||||
const mfaAttempts = new Map<string, { count: number; first: number }>();
|
||||
const forgotAttempts = new Map<string, { count: number; first: number }>();
|
||||
const resetAttempts = new Map<string, { count: number; first: number }>();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of loginAttempts) {
|
||||
if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key);
|
||||
}
|
||||
for (const [key, record] of mfaAttempts) {
|
||||
if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key);
|
||||
}
|
||||
for (const [key, record] of forgotAttempts) {
|
||||
if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key);
|
||||
}
|
||||
for (const [key, record] of resetAttempts) {
|
||||
if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key);
|
||||
}
|
||||
}, RATE_LIMIT_CLEANUP);
|
||||
|
||||
function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const key = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const record = store.get(key);
|
||||
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
|
||||
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
|
||||
}
|
||||
if (!record || now - record.first >= windowMs) {
|
||||
store.set(key, { count: 1, first: now });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
|
||||
const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts);
|
||||
const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts);
|
||||
const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
|
||||
const user = (req as OptionalAuthRequest).user;
|
||||
res.json(getAppConfig(user));
|
||||
});
|
||||
|
||||
router.post('/demo-login', (req: Request, res: Response) => {
|
||||
const result = demoLogin();
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
setAuthCookie(res, result.token!, req);
|
||||
res.json({ token: result.token, user: result.user });
|
||||
});
|
||||
|
||||
router.get('/invite/:token', authLimiter, (req: Request, res: Response) => {
|
||||
const result = validateInviteToken(req.params.token);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at });
|
||||
});
|
||||
|
||||
router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
const result = registerUser(req.body);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
|
||||
setAuthCookie(res, result.token!, req);
|
||||
res.status(201).json({ token: result.token, user: result.user });
|
||||
});
|
||||
|
||||
router.post('/login', authLimiter, async (req: Request, res: Response) => {
|
||||
const started = Date.now();
|
||||
const result = loginUser(req.body);
|
||||
if (result.auditAction) {
|
||||
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
||||
}
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < LOGIN_MIN_LATENCY_MS) {
|
||||
await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
|
||||
}
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
|
||||
setAuthCookie(res, result.token!, req);
|
||||
res.json({ token: result.token, user: result.user });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password reset (forgot / complete)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Generic OK response — identical regardless of email existence, to
|
||||
// prevent enumeration via response body OR status code.
|
||||
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
||||
// Minimum time we spend inside the forgot/login handlers so a "no such
|
||||
// user" path does not complete noticeably faster than a real operation.
|
||||
const FORGOT_MIN_LATENCY_MS = 350;
|
||||
const LOGIN_MIN_LATENCY_MS = 350;
|
||||
|
||||
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
||||
const started = Date.now();
|
||||
const rawEmail = typeof req.body?.email === 'string' ? req.body.email : '';
|
||||
const ip = getClientIp(req);
|
||||
|
||||
const outcome = requestPasswordReset(rawEmail, ip);
|
||||
|
||||
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
|
||||
// Build the reset URL from the server-side canonical APP_URL (or
|
||||
// first ALLOWED_ORIGINS entry) — never from request headers. A
|
||||
// crafted `Origin` / `Host` / `Referer` would otherwise put an
|
||||
// attacker-controlled domain into the emailed reset link while the
|
||||
// token itself is still legitimate.
|
||||
const origin = getAppUrl();
|
||||
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
|
||||
|
||||
// Audit the REQUEST always — even for "no user" — so abuse is visible.
|
||||
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
|
||||
|
||||
try {
|
||||
const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
|
||||
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
|
||||
} catch (err) {
|
||||
// Never surface delivery failure to the caller — still respond ok.
|
||||
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
|
||||
}
|
||||
} else {
|
||||
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
|
||||
}
|
||||
|
||||
// Pad the response so timing doesn't reveal outcome.
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < FORGOT_MIN_LATENCY_MS) {
|
||||
await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed));
|
||||
}
|
||||
res.json(GENERIC_FORGOT_RESPONSE);
|
||||
});
|
||||
|
||||
router.post('/reset-password', resetLimiter, (req: Request, res: Response) => {
|
||||
const ip = getClientIp(req);
|
||||
const result = resetPassword(req.body);
|
||||
if (result.error) {
|
||||
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
|
||||
return res.status(result.status!).json({ error: result.error });
|
||||
}
|
||||
if (result.mfa_required) {
|
||||
return res.status(200).json({ mfa_required: true });
|
||||
}
|
||||
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
|
||||
// Purposefully do NOT auto-login — the user just demonstrated they
|
||||
// have email+password access; asking them to sign in fresh is the
|
||||
// standard, safer UX.
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = getCurrentUser(authReq.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
router.post('/logout', (req: Request, res: Response) => {
|
||||
clearAuthCookie(res, req);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = changePassword(authReq.user.id, authReq.user.email, req.body);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/me', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = deleteAccount(authReq.user.id, authReq.user.email, authReq.user.role);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(updateMapsKey(authReq.user.id, req.body.maps_api_key));
|
||||
});
|
||||
|
||||
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(updateApiKeys(authReq.user.id, req.body));
|
||||
});
|
||||
|
||||
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = updateSettings(authReq.user.id, req.body);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: result.success, user: result.user });
|
||||
});
|
||||
|
||||
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = getSettings(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ settings: result.settings });
|
||||
});
|
||||
|
||||
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
||||
res.json(await saveAvatar(authReq.user.id, req.file.filename));
|
||||
});
|
||||
|
||||
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(await deleteAvatar(authReq.user.id));
|
||||
});
|
||||
|
||||
router.get('/users', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ users: listUsers(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.get('/validate-keys', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await validateKeys(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ maps: result.maps, weather: result.weather, maps_details: result.maps_details });
|
||||
});
|
||||
|
||||
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = getAppSettings(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = updateAppSettings(authReq.user.id, req.body);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'settings.app_update',
|
||||
ip: getClientIp(req),
|
||||
details: result.auditSummary,
|
||||
debugDetails: result.auditDebugDetails,
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getTravelStats(authReq.user.id));
|
||||
});
|
||||
|
||||
router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
|
||||
const result = verifyMfaLogin(req.body);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
setAuthCookie(res, result.token!, req);
|
||||
res.json({ token: result.token, user: result.user });
|
||||
});
|
||||
|
||||
router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = setupMfa(authReq.user.id, authReq.user.email);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
result.qrPromise!
|
||||
.then((qr_svg: string) => {
|
||||
res.json({ secret: result.secret, otpauth_url: result.otpauth_url, qr_svg });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[MFA] QR code generation error:', err);
|
||||
res.status(500).json({ error: 'Could not generate QR code' });
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = enableMfa(authReq.user.id, req.body.code);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||
res.json({ success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes });
|
||||
});
|
||||
|
||||
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = disableMfa(authReq.user.id, authReq.user.email, req.body);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||
res.json({ success: true, mfa_enabled: result.mfa_enabled });
|
||||
});
|
||||
|
||||
// --- MCP Token Management ---
|
||||
|
||||
router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ tokens: listMcpTokens(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = createMcpToken(authReq.user.id, req.body.name);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.status(201).json({ token: result.token });
|
||||
});
|
||||
|
||||
router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = deleteMcpToken(authReq.user.id, req.params.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Short-lived single-use token for WebSocket connections
|
||||
router.post('/ws-token', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = createWsToken(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ token: result.token });
|
||||
});
|
||||
|
||||
// Short-lived single-use token for direct resource URLs
|
||||
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const token = createResourceToken(authReq.user.id, req.body.purpose);
|
||||
if (!token) return res.status(503).json({ error: 'Service unavailable' });
|
||||
res.json(token);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
// Exported for test resets only — do not use in production code
|
||||
export { loginAttempts, mfaAttempts };
|
||||
@@ -1,197 +0,0 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import {
|
||||
listBackups,
|
||||
createBackup,
|
||||
restoreFromZip,
|
||||
getAutoSettings,
|
||||
updateAutoSettings,
|
||||
deleteBackup,
|
||||
isValidBackupFilename,
|
||||
backupFilePath,
|
||||
backupFileExists,
|
||||
checkRateLimit,
|
||||
getUploadTmpDir,
|
||||
BACKUP_RATE_WINDOW,
|
||||
MAX_BACKUP_UPLOAD_SIZE,
|
||||
} from '../services/backupService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate, adminOnly);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate-limiter middleware (HTTP concern wrapping service-level check)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function backupRateLimiter(maxAttempts: number, windowMs: number) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const key = req.ip || 'unknown';
|
||||
if (!checkRateLimit(key, maxAttempts, windowMs)) {
|
||||
return res.status(429).json({ error: 'Too many backup requests. Please try again later.' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/list', (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ backups: listBackups() });
|
||||
} catch (err: unknown) {
|
||||
res.status(500).json({ error: 'Error loading backups' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const backup = await createBackup();
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.create',
|
||||
resource: backup.filename,
|
||||
ip: getClientIp(req),
|
||||
details: { size: backup.size },
|
||||
});
|
||||
res.json({ success: true, backup });
|
||||
} catch (err: unknown) {
|
||||
res.status(500).json({ error: 'Error creating backup' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/download/:filename', (req: Request, res: Response) => {
|
||||
const { filename } = req.params;
|
||||
|
||||
if (!isValidBackupFilename(filename)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
if (!backupFileExists(filename)) {
|
||||
return res.status(404).json({ error: 'Backup not found' });
|
||||
}
|
||||
|
||||
res.download(backupFilePath(filename), filename);
|
||||
});
|
||||
|
||||
router.post('/restore/:filename', async (req: Request, res: Response) => {
|
||||
const { filename } = req.params;
|
||||
if (!isValidBackupFilename(filename)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
const zipPath = backupFilePath(filename);
|
||||
if (!backupFileExists(filename)) {
|
||||
return res.status(404).json({ error: 'Backup not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await restoreFromZip(zipPath);
|
||||
if (!result.success) {
|
||||
return res.status(result.status || 400).json({ error: result.error });
|
||||
}
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.restore',
|
||||
resource: filename,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' });
|
||||
}
|
||||
});
|
||||
|
||||
const uploadTmp = multer({
|
||||
dest: getUploadTmpDir(),
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.originalname.endsWith('.zip')) cb(null, true);
|
||||
else cb(new Error('Only ZIP files allowed'));
|
||||
},
|
||||
limits: { fileSize: MAX_BACKUP_UPLOAD_SIZE },
|
||||
});
|
||||
|
||||
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const zipPath = req.file.path;
|
||||
const origName = req.file.originalname || 'upload.zip';
|
||||
|
||||
try {
|
||||
const result = await restoreFromZip(zipPath);
|
||||
if (!result.success) {
|
||||
return res.status(result.status || 400).json({ error: result.error });
|
||||
}
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.upload_restore',
|
||||
resource: origName,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' });
|
||||
} finally {
|
||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/auto-settings', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const data = getAutoSettings();
|
||||
res.json(data);
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] GET auto-settings:', err);
|
||||
res.status(500).json({ error: 'Could not load backup settings' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/auto-settings', (req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = updateAutoSettings((req.body || {}) as Record<string, unknown>);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.auto_settings',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
|
||||
});
|
||||
res.json({ settings });
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] PUT auto-settings:', err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
res.status(500).json({
|
||||
error: 'Could not save auto-backup settings',
|
||||
detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:filename', (req: Request, res: Response) => {
|
||||
const { filename } = req.params;
|
||||
|
||||
if (!isValidBackupFilename(filename)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
if (!backupFileExists(filename)) {
|
||||
return res.status(404).json({ error: 'Backup not found' });
|
||||
}
|
||||
|
||||
deleteBackup(filename);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'backup.delete',
|
||||
resource: filename,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,189 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import { db } from '../db/database';
|
||||
import {
|
||||
verifyTripAccess,
|
||||
listBudgetItems,
|
||||
createBudgetItem,
|
||||
updateBudgetItem,
|
||||
deleteBudgetItem,
|
||||
updateMembers,
|
||||
toggleMemberPaid,
|
||||
getPerPersonSummary,
|
||||
calculateSettlement,
|
||||
reorderBudgetItems,
|
||||
reorderBudgetCategories,
|
||||
} from '../services/budgetService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
res.json({ items: listBudgetItems(tripId) });
|
||||
});
|
||||
|
||||
router.get('/summary/per-person', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
if (!verifyTripAccess(Number(tripId), authReq.user.id))
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
res.json({ summary: getPerPersonSummary(tripId) });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { name } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
|
||||
const item = createBudgetItem(tripId, req.body);
|
||||
res.status(201).json({ item });
|
||||
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/reorder/items', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
reorderBudgetItems(tripId, orderedIds);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'budget:reordered', { orderedIds }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/reorder/categories', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { orderedCategories } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
reorderBudgetCategories(tripId, orderedCategories);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'budget:reordered', { orderedCategories }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const updated = updateBudgetItem(id, tripId, req.body);
|
||||
if (!updated) return res.status(404).json({ error: 'Budget item not found' });
|
||||
|
||||
// Sync price back to linked reservation
|
||||
if (updated.reservation_id && req.body.total_price !== undefined) {
|
||||
try {
|
||||
const reservation = db.prepare('SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?').get(updated.reservation_id, tripId) as { id: number; metadata: string | null } | undefined;
|
||||
if (reservation) {
|
||||
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
meta.price = String(updated.total_price);
|
||||
db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id);
|
||||
const updatedRes = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id);
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[budget] Failed to sync price to reservation:', err);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ item: updated });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const access = verifyTripAccess(Number(tripId), authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { user_ids } = req.body;
|
||||
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
|
||||
|
||||
const result = updateMembers(id, tripId, user_ids);
|
||||
if (!result) return res.status(404).json({ error: 'Budget item not found' });
|
||||
|
||||
res.json({ members: result.members, item: result.item });
|
||||
broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members: result.members, persons: result.item.persons }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id, userId } = req.params;
|
||||
|
||||
const access = verifyTripAccess(Number(tripId), authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { paid } = req.body;
|
||||
const member = toggleMemberPaid(id, userId, paid);
|
||||
res.json({ member });
|
||||
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.get('/settlement', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
if (!verifyTripAccess(Number(tripId), authReq.user.id))
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
res.json(calculateSettlement(tripId));
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!deleteBudgetItem(id, tripId))
|
||||
return res.status(404).json({ error: 'Budget item not found' });
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,35 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as categoryService from '../services/categoryService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, (_req: Request, res: Response) => {
|
||||
res.json({ categories: categoryService.listCategories() });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, adminOnly, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { name, color, icon } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||
const category = categoryService.createCategory(authReq.user.id, name, color, icon);
|
||||
res.status(201).json({ category });
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
|
||||
const { name, color, icon } = req.body;
|
||||
if (!categoryService.getCategoryById(req.params.id))
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
const category = categoryService.updateCategory(req.params.id, name, color, icon);
|
||||
res.json({ category });
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
|
||||
if (!categoryService.getCategoryById(req.params.id))
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
categoryService.deleteCategory(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,328 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import { db } from '../db/database';
|
||||
import { BLOCKED_EXTENSIONS } from '../services/fileService';
|
||||
import {
|
||||
verifyTripAccess,
|
||||
listNotes,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
addNoteFile,
|
||||
getFormattedNoteById,
|
||||
deleteNoteFile,
|
||||
listPolls,
|
||||
createPoll,
|
||||
votePoll,
|
||||
closePoll,
|
||||
deletePoll,
|
||||
listMessages,
|
||||
createMessage,
|
||||
deleteMessage,
|
||||
addOrRemoveReaction,
|
||||
fetchLinkPreview,
|
||||
} from '../services/collabService';
|
||||
|
||||
const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
const filesDir = path.join(__dirname, '../../uploads/files');
|
||||
const noteUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir) },
|
||||
filename: (_req, file, cb) => { cb(null, `${uuidv4()}${path.extname(file.originalname)}`) },
|
||||
}),
|
||||
limits: { fileSize: MAX_NOTE_FILE_SIZE },
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
// Share the single BLOCKED_EXTENSIONS list from fileService so
|
||||
// executable/script attachments can't sneak in via collab when the
|
||||
// main uploader already rejects them.
|
||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
|
||||
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Notes */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
router.get('/notes', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
res.json({ notes: listNotes(tripId) });
|
||||
});
|
||||
|
||||
router.post('/notes', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { title, content, category, color, website } = req.body;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
|
||||
const formatted = createNote(tripId, authReq.user.id, { title, content, category, color, website });
|
||||
res.status(201).json({ note: formatted });
|
||||
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { title, content, category, color, pinned, website } = req.body;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const formatted = updateNote(tripId, id, { title, content, category, color, pinned, website });
|
||||
if (!formatted) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
res.json({ note: formatted });
|
||||
broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.delete('/notes/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!deleteNote(tripId, id)) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Note files */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const access = verifyTripAccess(Number(tripId), authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_upload', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission to upload files' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const result = addNoteFile(tripId, id, req.file);
|
||||
if (!result) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
res.status(201).json(result);
|
||||
broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id, fileId } = req.params;
|
||||
const access = verifyTripAccess(Number(tripId), authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!deleteNoteFile(id, fileId)) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Polls */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
router.get('/polls', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
res.json({ polls: listPolls(tripId) });
|
||||
});
|
||||
|
||||
router.post('/polls', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { question, options, multiple, multiple_choice, deadline } = req.body;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!question) return res.status(400).json({ error: 'Question is required' });
|
||||
if (!Array.isArray(options) || options.length < 2) {
|
||||
return res.status(400).json({ error: 'At least 2 options are required' });
|
||||
}
|
||||
|
||||
const poll = createPoll(tripId, authReq.user.id, { question, options, multiple, multiple_choice, deadline });
|
||||
res.status(201).json({ poll });
|
||||
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { option_index } = req.body;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const result = votePoll(tripId, id, authReq.user.id, option_index);
|
||||
if (result.error === 'not_found') return res.status(404).json({ error: 'Poll not found' });
|
||||
if (result.error === 'closed') return res.status(400).json({ error: 'Poll is closed' });
|
||||
if (result.error === 'invalid_index') return res.status(400).json({ error: 'Invalid option index' });
|
||||
|
||||
res.json({ poll: result.poll });
|
||||
broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const updatedPoll = closePoll(tripId, id);
|
||||
if (!updatedPoll) return res.status(404).json({ error: 'Poll not found' });
|
||||
|
||||
res.json({ poll: updatedPoll });
|
||||
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.delete('/polls/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!deletePoll(tripId, id)) return res.status(404).json({ error: 'Poll not found' });
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Messages */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
router.get('/messages', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { before } = req.query;
|
||||
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
res.json({ messages: listMessages(tripId, before as string | undefined) });
|
||||
});
|
||||
|
||||
router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { text, reply_to } = req.body;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
|
||||
|
||||
const result = createMessage(tripId, authReq.user.id, text, reply_to);
|
||||
if (result.error === 'reply_not_found') return res.status(400).json({ error: 'Reply target message not found' });
|
||||
|
||||
res.status(201).json({ message: result.message });
|
||||
broadcast(tripId, 'collab:message:created', { message: result.message }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about new chat message
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
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();
|
||||
send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reactions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { emoji } = req.body;
|
||||
const access = verifyTripAccess(Number(tripId), authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
|
||||
|
||||
const result = addOrRemoveReaction(id, tripId, authReq.user.id, emoji);
|
||||
if (!result.found) return res.status(404).json({ error: 'Message not found' });
|
||||
|
||||
res.json({ reactions: result.reactions });
|
||||
broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions: result.reactions }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Delete message */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const result = deleteMessage(tripId, id, authReq.user.id);
|
||||
if (result.error === 'not_found') return res.status(404).json({ error: 'Message not found' });
|
||||
if (result.error === 'not_owner') return res.status(403).json({ error: 'You can only delete your own messages' });
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: result.username || authReq.user.username }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Link preview */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
router.get('/link-preview', authenticate, async (req: Request, res: Response) => {
|
||||
const { url } = req.query as { url?: string };
|
||||
if (!url) return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
const preview = await fetchLinkPreview(url);
|
||||
const asAny = preview as any;
|
||||
if (asAny.error) return res.status(400).json({ error: asAny.error });
|
||||
res.json(preview);
|
||||
} catch {
|
||||
res.json({ title: null, description: null, image: null, url });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,66 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as dayNoteService from '../services/dayNoteService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, dayId } = req.params;
|
||||
if (!dayNoteService.verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
res.json({ notes: dayNoteService.listNotes(dayId, tripId) });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, dayId } = req.params;
|
||||
const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!dayNoteService.dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||
|
||||
const { text, time, icon, sort_order } = req.body;
|
||||
if (!text?.trim()) return res.status(400).json({ error: 'Text required' });
|
||||
|
||||
const note = dayNoteService.createNote(dayId, tripId, text, time, icon, sort_order);
|
||||
res.status(201).json({ note });
|
||||
broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, dayId, id } = req.params;
|
||||
const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const current = dayNoteService.getNote(id, dayId, tripId);
|
||||
if (!current) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
const { text, time, icon, sort_order } = req.body;
|
||||
const updated = dayNoteService.updateNote(id, current, { text, time, icon, sort_order });
|
||||
res.json({ note: updated });
|
||||
broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note: updated }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, dayId, id } = req.params;
|
||||
const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!dayNoteService.getNote(id, dayId, tripId)) return res.status(404).json({ error: 'Note not found' });
|
||||
dayNoteService.deleteNote(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,133 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as dayService from '../services/dayService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId } = req.params;
|
||||
res.json(dayService.listDays(tripId));
|
||||
});
|
||||
|
||||
router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { date, notes } = req.body;
|
||||
|
||||
const day = dayService.createDay(tripId, date, notes);
|
||||
res.status(201).json({ day });
|
||||
broadcast(tripId, 'day:created', { day }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const current = dayService.getDay(id, tripId);
|
||||
if (!current) return res.status(404).json({ error: 'Day not found' });
|
||||
|
||||
const { notes, title } = req.body;
|
||||
const day = dayService.updateDay(id, current, { notes, title });
|
||||
res.json({ day });
|
||||
broadcast(tripId, 'day:updated', { day }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
if (!dayService.getDay(id, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||
|
||||
dayService.deleteDay(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accommodations sub-router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const accommodationsRouter = express.Router({ mergeParams: true });
|
||||
|
||||
accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId } = req.params;
|
||||
res.json({ accommodations: dayService.listAccommodations(tripId) });
|
||||
});
|
||||
|
||||
accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body;
|
||||
|
||||
if (!place_id || !start_day_id || !end_day_id) {
|
||||
return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' });
|
||||
}
|
||||
|
||||
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
|
||||
|
||||
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
res.status(201).json({ accommodation });
|
||||
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
|
||||
broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const existing = dayService.getAccommodation(id, tripId);
|
||||
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
|
||||
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body;
|
||||
|
||||
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
|
||||
|
||||
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
res.json({ accommodation });
|
||||
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
if (!dayService.getAccommodation(id, tripId)) return res.status(404).json({ error: 'Accommodation not found' });
|
||||
|
||||
const { linkedReservationId, deletedBudgetItemId } = dayService.deleteAccommodation(id);
|
||||
if (linkedReservationId) {
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
if (deletedBudgetItemId) {
|
||||
broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
export default router;
|
||||
export { accommodationsRouter };
|
||||
@@ -1,284 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||
import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
import { AuthRequest } from '../types';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import {
|
||||
MAX_FILE_SIZE,
|
||||
BLOCKED_EXTENSIONS,
|
||||
filesDir,
|
||||
getAllowedExtensions,
|
||||
verifyTripAccess,
|
||||
formatFile,
|
||||
resolveFilePath,
|
||||
authenticateDownload,
|
||||
listFiles,
|
||||
getFileById,
|
||||
getFileByIdFull,
|
||||
getDeletedFile,
|
||||
createFile,
|
||||
updateFile,
|
||||
toggleStarred,
|
||||
softDeleteFile,
|
||||
restoreFile,
|
||||
permanentDeleteFile,
|
||||
emptyTrash,
|
||||
createFileLink,
|
||||
deleteFileLink,
|
||||
getFileLinks,
|
||||
} from '../services/fileService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multer setup (HTTP middleware — stays in route)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true });
|
||||
cb(null, filesDir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
|
||||
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
}
|
||||
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
||||
const fileExt = ext.replace('.', '');
|
||||
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
err.statusCode = 400;
|
||||
cb(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Authenticated file download (supports cookie, Bearer header, or ?token= query param)
|
||||
router.get('/:id/download', (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const auth = authenticateDownload(req);
|
||||
if ('error' in auth) return res.status(auth.status).json({ error: auth.error });
|
||||
|
||||
const trip = verifyTripAccess(tripId, auth.userId);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const file = getFileById(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
const { resolved, safe } = resolveFilePath(file.filename);
|
||||
if (!safe) return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
// Serve Apple Wallet passes inline with the canonical MIME type so Safari
|
||||
// (iOS/macOS) hands them off to Wallet instead of downloading as a blob.
|
||||
if (path.extname(resolved).toLowerCase() === '.pkpass') {
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.pkpass');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`);
|
||||
}
|
||||
|
||||
res.sendFile(resolved);
|
||||
});
|
||||
|
||||
// List files (excludes soft-deleted by default)
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const showTrash = req.query.trash === 'true';
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
res.json({ files: listFiles(tripId, showTrash) });
|
||||
});
|
||||
|
||||
// Upload file
|
||||
router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { user_id: tripOwnerId } = authReq.trip!;
|
||||
if (!checkPermission('file_upload', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission to upload files' });
|
||||
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const { place_id, description, reservation_id } = req.body;
|
||||
const created = createFile(tripId, req.file, authReq.user.id, { place_id, description, reservation_id });
|
||||
res.status(201).json({ file: created });
|
||||
broadcast(tripId, 'file:created', { file: created }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Update file metadata
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { description, place_id, reservation_id } = req.body;
|
||||
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission to edit files' });
|
||||
|
||||
const file = getFileById(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
const updated = updateFile(id, file, { description, place_id, reservation_id });
|
||||
res.json({ file: updated });
|
||||
broadcast(tripId, 'file:updated', { file: updated }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Toggle starred
|
||||
router.patch('/:id/star', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const file = getFileById(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
const updated = toggleStarred(id, file.starred);
|
||||
res.json({ file: updated });
|
||||
broadcast(tripId, 'file:updated', { file: updated }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Soft-delete (move to trash)
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const access = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_delete', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission to delete files' });
|
||||
|
||||
const file = getFileById(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
softDeleteFile(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Restore from trash
|
||||
router.post('/:id/restore', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const file = getDeletedFile(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found in trash' });
|
||||
|
||||
const restored = restoreFile(id);
|
||||
res.json({ file: restored });
|
||||
broadcast(tripId, 'file:created', { file: restored }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Permanently delete from trash
|
||||
router.delete('/:id/permanent', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const file = getDeletedFile(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found in trash' });
|
||||
|
||||
await permanentDeleteFile(file);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Empty entire trash
|
||||
router.delete('/trash/empty', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const deleted = await emptyTrash(tripId);
|
||||
res.json({ success: true, deleted });
|
||||
});
|
||||
|
||||
// Link a file to a reservation (many-to-many)
|
||||
router.post('/:id/link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { reservation_id, assignment_id, place_id } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const file = getFileById(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
const links = createFileLink(id, { reservation_id, assignment_id, place_id });
|
||||
res.json({ success: true, links });
|
||||
});
|
||||
|
||||
// Unlink a file from a reservation
|
||||
router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id, linkId } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
deleteFileLink(linkId, id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Get all links for a file
|
||||
router.get('/:id/links', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const links = getFileLinks(id);
|
||||
res.json({ links });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,393 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as svc from '../services/journeyService';
|
||||
import { db } from '../db/database';
|
||||
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
||||
import { uploadToImmich } from '../services/memories/immichService';
|
||||
import { getAllowedExtensions } from '../services/fileService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const uploadsBase = path.join(__dirname, '../../uploads/journey');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true });
|
||||
cb(null, uploadsBase);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
cb(null, `${crypto.randomUUID()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
|
||||
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
|
||||
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
}
|
||||
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
|
||||
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
||||
if (!allowed.includes('*') && !allowed.includes(ext)) {
|
||||
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
|
||||
err.statusCode = 400;
|
||||
return cb(err);
|
||||
}
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: imageFilter,
|
||||
});
|
||||
|
||||
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ journeys: svc.listJourneys(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { title, subtitle, trip_ids } = req.body || {};
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
const journey = svc.createJourney(authReq.user.id, {
|
||||
title: title.trim(),
|
||||
subtitle,
|
||||
trip_ids: Array.isArray(trip_ids) ? trip_ids : [],
|
||||
});
|
||||
res.status(201).json(journey);
|
||||
});
|
||||
|
||||
router.get('/suggestions', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ trips: svc.getSuggestions(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.get('/available-trips', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ trips: svc.listUserTrips(authReq.user.id) });
|
||||
});
|
||||
|
||||
// ── Entries (prefix /entries — before /:id) ──────────────────────────────
|
||||
|
||||
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}, req.headers['x-socket-id'] as string);
|
||||
if (!result) return res.status(404).json({ error: 'Entry not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id, req.headers['x-socket-id'] as string)) {
|
||||
return res.status(404).json({ error: 'Entry not found' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
|
||||
|
||||
router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||
|
||||
const results: any[] = [];
|
||||
for (const file of files) {
|
||||
const relativePath = `journey/${file.filename}`;
|
||||
const photo = svc.addPhoto(
|
||||
Number(req.params.entryId),
|
||||
authReq.user.id,
|
||||
relativePath,
|
||||
undefined,
|
||||
req.body?.caption
|
||||
);
|
||||
if (photo) {
|
||||
// Mirror to Immich only when the user has explicitly opted in via the
|
||||
// Immich integration settings. Avoids the "surprise upload" in #730
|
||||
// where a write-capable API key implicitly enabled mirroring.
|
||||
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(authReq.user.id) as { immich_auto_upload?: number } | undefined;
|
||||
if (prefs?.immich_auto_upload) {
|
||||
try {
|
||||
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
||||
if (immichId) {
|
||||
// photo.id is now the gallery photo id (journey_photos.id)
|
||||
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
|
||||
(photo as any).provider = 'immich';
|
||||
(photo as any).asset_id = immichId;
|
||||
(photo as any).owner_id = authReq.user.id;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
results.push(photo);
|
||||
}
|
||||
}
|
||||
|
||||
if (!results.length) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.status(201).json({ photos: results });
|
||||
});
|
||||
|
||||
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
|
||||
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
|
||||
|
||||
// Batch mode: { provider, asset_ids: string[] }
|
||||
if (Array.isArray(asset_ids) && provider) {
|
||||
const added: any[] = [];
|
||||
for (const id of asset_ids) {
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
|
||||
if (photo) added.push(photo);
|
||||
}
|
||||
return res.status(201).json({ photos: added, added: added.length });
|
||||
}
|
||||
|
||||
// Single mode (backward compat)
|
||||
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
|
||||
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||
res.status(201).json(photo);
|
||||
});
|
||||
|
||||
// Link a gallery photo to an entry
|
||||
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
// Accept journey_photo_id (new) or photo_id (legacy alias) for backwards compat
|
||||
const journeyPhotoId = (req.body || {}).journey_photo_id ?? (req.body || {}).photo_id;
|
||||
if (!journeyPhotoId) return res.status(400).json({ error: 'journey_photo_id required' });
|
||||
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(journeyPhotoId), authReq.user.id);
|
||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
// Unlink a photo from a specific entry (gallery row is preserved)
|
||||
router.delete('/entries/:entryId/photos/:journeyPhotoId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const ok = svc.unlinkPhotoFromEntry(Number(req.params.entryId), Number(req.params.journeyPhotoId), authReq.user.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Not found or not allowed' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
|
||||
if (!result) return res.status(404).json({ error: 'Photo not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Hard-delete: removes gallery row + cascades to all entry links + deletes file if unreferenced
|
||||
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||
if (photo.file_path) {
|
||||
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
|
||||
try { fs.unlinkSync(fullPath); } catch {}
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
|
||||
|
||||
// Upload photos directly to the journey gallery (no entry association)
|
||||
router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||
|
||||
const filePaths = files.map(f => ({ path: `journey/${f.filename}` }));
|
||||
const photos = svc.uploadGalleryPhotos(Number(req.params.id), authReq.user.id, filePaths);
|
||||
if (!photos.length) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.status(201).json({ photos });
|
||||
});
|
||||
|
||||
// Add provider photos to gallery only (no entry link)
|
||||
router.post('/:id/gallery/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { provider, asset_id, asset_ids, passphrase } = req.body || {};
|
||||
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
|
||||
|
||||
if (Array.isArray(asset_ids) && provider) {
|
||||
const added: any[] = [];
|
||||
for (const id of asset_ids) {
|
||||
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, String(id), undefined, pp);
|
||||
if (photo) added.push(photo);
|
||||
}
|
||||
return res.status(201).json({ photos: added, added: added.length });
|
||||
}
|
||||
|
||||
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
||||
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, asset_id, undefined, pp);
|
||||
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||
res.status(201).json(photo);
|
||||
});
|
||||
|
||||
// Hard-delete a gallery photo (removes from all entries)
|
||||
router.delete('/:id/gallery/:journeyPhotoId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photo = svc.deleteGalleryPhoto(Number(req.params.journeyPhotoId), authReq.user.id);
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found or not allowed' });
|
||||
if (photo.file_path) {
|
||||
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
|
||||
try { fs.unlinkSync(fullPath); } catch {}
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
|
||||
|
||||
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const data = svc.getJourneyFull(Number(req.params.id), authReq.user.id);
|
||||
if (!data) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.patch('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, req.body || {});
|
||||
if (!result) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post('/:id/cover', authenticate, upload.single('cover'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const relativePath = `journey/${req.file.filename}`;
|
||||
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, { cover_image: relativePath });
|
||||
if (!result) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.deleteJourney(Number(req.params.id), authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Journey not found' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Journey trips ────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/trips', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { trip_id } = req.body || {};
|
||||
if (!trip_id) return res.status(400).json({ error: 'trip_id required' });
|
||||
if (!svc.addTripToJourney(Number(req.params.id), trip_id, authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/trips/:tripId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.removeTripFromJourney(Number(req.params.id), Number(req.params.tripId), authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Entries under journey ────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/entries', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const entries = svc.listEntries(Number(req.params.id), authReq.user.id);
|
||||
if (!entries) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json({ entries });
|
||||
});
|
||||
|
||||
router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { entry_date } = req.body || {};
|
||||
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
|
||||
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body, req.headers['x-socket-id'] as string);
|
||||
if (!entry) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.status(201).json(entry);
|
||||
});
|
||||
|
||||
router.put('/:id/entries/reorder', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const orderedIds = (req.body || {}).orderedIds;
|
||||
if (!Array.isArray(orderedIds) || !orderedIds.every(id => Number.isFinite(Number(id)))) {
|
||||
return res.status(400).json({ error: 'orderedIds must be an array of numbers' });
|
||||
}
|
||||
if (!svc.reorderEntries(Number(req.params.id), authReq.user.id, orderedIds.map(Number), req.headers['x-socket-id'] as string)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Contributors ─────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/contributors', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { user_id, role } = req.body || {};
|
||||
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
||||
if (!svc.addContributor(Number(req.params.id), authReq.user.id, user_id, role || 'viewer')) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.status(201).json({ success: true });
|
||||
});
|
||||
|
||||
router.patch('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { role } = req.body || {};
|
||||
if (!svc.updateContributorRole(Number(req.params.id), authReq.user.id, Number(req.params.userId), role)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.removeContributor(Number(req.params.id), authReq.user.id, Number(req.params.userId))) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── User Preferences ─────────────────────────────────────────────────────
|
||||
|
||||
router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body);
|
||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Share Link ────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const link = getJourneyShareLink(Number(req.params.id));
|
||||
res.json({ link });
|
||||
});
|
||||
|
||||
router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { share_timeline, share_gallery, share_map } = req.body || {};
|
||||
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
|
||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!deleteJourneyShareLink(Number(req.params.id), authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,56 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService';
|
||||
import { streamPhoto } from '../services/memories/photoResolverService';
|
||||
import { streamImmichAsset } from '../services/memories/immichService';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:token', (req: Request, res: Response) => {
|
||||
const data = getPublicJourney(req.params.token);
|
||||
if (!data) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Unified public photo proxy — uses trek_photo_id
|
||||
router.get('/:token/photos/:photoId/:kind', async (req: Request, res: Response) => {
|
||||
const { token, photoId, kind } = req.params;
|
||||
const valid = validateShareTokenForPhoto(token, Number(photoId));
|
||||
if (!valid) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
await streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original');
|
||||
});
|
||||
|
||||
// Legacy public photo proxy — validates share token instead of auth
|
||||
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
|
||||
const { token, provider, assetId, ownerId, kind } = req.params;
|
||||
|
||||
const valid = validateShareTokenForAsset(token, assetId);
|
||||
if (!valid) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (provider === 'local') {
|
||||
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
|
||||
const resolved = path.resolve(filePath);
|
||||
const uploadsDir = path.resolve(__dirname, '../../uploads');
|
||||
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
return res.sendFile(resolved);
|
||||
}
|
||||
|
||||
const effectiveOwnerId = valid.ownerId || Number(ownerId);
|
||||
if (provider === 'immich') {
|
||||
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
|
||||
} else {
|
||||
try {
|
||||
const { streamSynologyAsset } = await import('../services/memories/synologyService');
|
||||
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
|
||||
} catch {
|
||||
res.status(404).json({ error: 'Provider not supported' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,162 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
searchPlaces,
|
||||
getPlaceDetails,
|
||||
getPlaceDetailsExpanded,
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
autocompletePlaces,
|
||||
} from '../services/mapsService';
|
||||
import { db } from '../db/database';
|
||||
import { serveFilePath } from '../services/placePhotoCache';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /search
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { query } = req.body;
|
||||
|
||||
if (!query) return res.status(400).json({ error: 'Search query is required' });
|
||||
|
||||
try {
|
||||
const result = await searchPlaces(authReq.user.id, query, req.query.lang as string);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
const message = err instanceof Error ? err.message : 'Search error';
|
||||
console.error('Maps search error:', err);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /autocomplete
|
||||
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
|
||||
const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
|
||||
if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' });
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
const { input, lang, locationBias } = req.body;
|
||||
|
||||
if (!input || typeof input !== 'string') {
|
||||
return res.status(400).json({ error: 'Input is required' });
|
||||
}
|
||||
|
||||
if (input.length > 200) {
|
||||
return res.status(400).json({ error: 'Input too long (max 200 chars)' });
|
||||
}
|
||||
|
||||
if (locationBias) {
|
||||
const { low, high } = locationBias;
|
||||
if (!low || !high
|
||||
|| !Number.isFinite(low.lat) || !Number.isFinite(low.lng)
|
||||
|| !Number.isFinite(high.lat) || !Number.isFinite(high.lng)) {
|
||||
return res.status(400).json({ error: 'Invalid locationBias: low and high must have finite lat and lng' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await autocompletePlaces(
|
||||
authReq.user.id,
|
||||
input,
|
||||
lang as string,
|
||||
locationBias as { low: { lat: number; lng: number }; high: { lat: number; lng: number } } | undefined,
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
const message = err instanceof Error ? err.message : 'Autocomplete error';
|
||||
console.error('Maps autocomplete error:', err);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /details/:placeId
|
||||
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||
const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
|
||||
if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true });
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
const { placeId } = req.params;
|
||||
const expand = req.query.expand as string | undefined;
|
||||
|
||||
try {
|
||||
const refresh = req.query.refresh === '1';
|
||||
const result = expand
|
||||
? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh)
|
||||
: await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
const message = err instanceof Error ? err.message : 'Error fetching place details';
|
||||
console.error('Maps details error:', err);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /place-photo/:placeId
|
||||
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { placeId } = req.params;
|
||||
|
||||
// Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed
|
||||
if (!placeId.startsWith('coords:')) {
|
||||
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||
if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null });
|
||||
}
|
||||
const lat = parseFloat(req.query.lat as string);
|
||||
const lng = parseFloat(req.query.lng as string);
|
||||
|
||||
try {
|
||||
const result = await getPlacePhoto(authReq.user.id, placeId, lat, lng, req.query.name as string);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
const message = err instanceof Error ? err.message : 'Error fetching photo';
|
||||
if (status >= 500) console.error('Place photo error:', err);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk
|
||||
router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => {
|
||||
const { placeId } = req.params;
|
||||
const fp = serveFilePath(placeId);
|
||||
if (!fp) return res.status(404).json({ error: 'Photo not cached' });
|
||||
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
res.sendFile(fp);
|
||||
});
|
||||
|
||||
// GET /reverse
|
||||
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
||||
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
|
||||
if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' });
|
||||
|
||||
try {
|
||||
const result = await reverseGeocode(lat, lng, lang);
|
||||
res.json(result);
|
||||
} catch {
|
||||
res.json({ name: null, address: null });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /resolve-url
|
||||
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
const result = await resolveGoogleMapsUrl(url);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 400;
|
||||
const message = err instanceof Error ? err.message : 'Failed to resolve URL';
|
||||
console.error('[Maps] URL resolve error:', message);
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,142 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { authenticate } from '../../middleware/auth';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { AuthRequest } from '../../types';
|
||||
import { getClientIp } from '../../services/auditLog';
|
||||
import {
|
||||
getConnectionSettings,
|
||||
saveImmichSettings,
|
||||
setImmichAutoUpload,
|
||||
testConnection,
|
||||
getConnectionStatus,
|
||||
browseTimeline,
|
||||
searchPhotos,
|
||||
streamImmichAsset,
|
||||
listAlbums,
|
||||
getAlbumPhotos,
|
||||
syncAlbumAssets,
|
||||
getAssetInfo,
|
||||
isValidAssetId,
|
||||
} from '../../services/memories/immichService';
|
||||
import { canAccessUserPhoto } from '../../services/memories/helpersService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── Immich Connection Settings ─────────────────────────────────────────────
|
||||
|
||||
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getConnectionSettings(authReq.user.id));
|
||||
});
|
||||
|
||||
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { immich_url, immich_api_key, auto_upload } = req.body;
|
||||
const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req));
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
if (typeof auto_upload === 'boolean') {
|
||||
setImmichAutoUpload(authReq.user.id, auto_upload);
|
||||
}
|
||||
if (result.warning) return res.json({ success: true, warning: result.warning });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(await getConnectionStatus(authReq.user.id));
|
||||
});
|
||||
|
||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' });
|
||||
res.json(await testConnection(immich_url, immich_api_key));
|
||||
});
|
||||
|
||||
// ── Browse Immich Library (for photo picker) ───────────────────────────────
|
||||
|
||||
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await browseTimeline(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ buckets: result.buckets });
|
||||
});
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to, size, page } = req.body;
|
||||
const pageNum = Math.max(1, Number(page) || 1);
|
||||
const pageSize = Math.min(Number(size) || 50, 200);
|
||||
const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const result = await getAssetInfo(authReq.user.id, assetId, Number(ownerId));
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
// ── Proxy Immich Assets ────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
await streamImmichAsset(res, authReq.user.id, assetId, 'thumbnail', Number(ownerId));
|
||||
});
|
||||
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/original', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
await streamImmichAsset(res, authReq.user.id, assetId, 'original', Number(ownerId));
|
||||
});
|
||||
|
||||
// ── Album Linking ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await listAlbums(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ albums: result.albums });
|
||||
});
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await getAlbumPhotos(authReq.user.id, req.params.albumId);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
const sid = req.headers['x-socket-id'] as string;
|
||||
const result = await syncAlbumAssets(tripId, linkId, authReq.user.id, sid);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true, added: result.added, total: result.total });
|
||||
if (result.added! > 0) {
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,150 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../../middleware/auth';
|
||||
import { AuthRequest } from '../../types';
|
||||
import {
|
||||
getSynologySettings,
|
||||
updateSynologySettings,
|
||||
getSynologyStatus,
|
||||
testSynologyConnection,
|
||||
listSynologyAlbums,
|
||||
getSynologyAlbumPhotos,
|
||||
syncSynologyAlbumLink,
|
||||
searchSynologyPhotos,
|
||||
getSynologyAssetInfo,
|
||||
streamSynologyAsset,
|
||||
} from '../../services/memories/synologyService';
|
||||
import { canAccessUserPhoto, handleServiceResult, fail, success } from '../../services/memories/helpersService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function _parseStringBodyField(value: unknown): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function _parseNumberBodyField(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
router.get('/settings', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
handleServiceResult(res, await getSynologySettings(authReq.user.id));
|
||||
});
|
||||
|
||||
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const synology_url = _parseStringBodyField(body.synology_url);
|
||||
const synology_username = _parseStringBodyField(body.synology_username);
|
||||
const synology_password = _parseStringBodyField(body.synology_password);
|
||||
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
|
||||
|
||||
if (!synology_url || !synology_username) {
|
||||
handleServiceResult(res, fail('URL and username are required', 400));
|
||||
}
|
||||
else {
|
||||
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
handleServiceResult(res, await getSynologyStatus(authReq.user.id));
|
||||
});
|
||||
|
||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const synology_url = _parseStringBodyField(body.synology_url);
|
||||
const synology_username = _parseStringBodyField(body.synology_username);
|
||||
const synology_password = _parseStringBodyField(body.synology_password);
|
||||
const synology_otp = _parseStringBodyField(body.synology_otp);
|
||||
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
|
||||
|
||||
if (!synology_url || !synology_username || !synology_password) {
|
||||
const missingFields: string[] = [];
|
||||
if (!synology_url) missingFields.push('URL');
|
||||
if (!synology_username) missingFields.push('Username');
|
||||
if (!synology_password) missingFields.push('Password');
|
||||
handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
|
||||
}
|
||||
else{
|
||||
handleServiceResult(res, await testSynologyConnection(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
|
||||
});
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
const sid = req.headers['x-socket-id'] as string;
|
||||
|
||||
handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId, sid));
|
||||
});
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const from = _parseStringBodyField(body.from);
|
||||
const to = _parseStringBodyField(body.to);
|
||||
let offset = _parseNumberBodyField(body.offset, 0);
|
||||
const page = _parseNumberBodyField(body.page, 1) - 1;
|
||||
let limit = _parseNumberBodyField(body.limit, 100);
|
||||
const size = _parseNumberBodyField(body.size, 0);
|
||||
if (size > 0) limit = size;
|
||||
if (page > 0) offset = page * limit;
|
||||
|
||||
handleServiceResult(res, await searchSynologyPhotos(
|
||||
authReq.user.id,
|
||||
from || undefined,
|
||||
to || undefined,
|
||||
offset,
|
||||
limit,
|
||||
));
|
||||
});
|
||||
|
||||
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, photoId, ownerId } = req.params;
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||
}
|
||||
else {
|
||||
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, photoId, ownerId, kind } = req.params;
|
||||
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
|
||||
const rawSize = String(req.query.size ?? 'sm');
|
||||
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
|
||||
if (kind !== 'thumbnail' && kind !== 'original') {
|
||||
return handleServiceResult(res, fail('Invalid asset kind', 400));
|
||||
}
|
||||
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||
}
|
||||
else{
|
||||
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,104 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../../middleware/auth';
|
||||
import { AuthRequest } from '../../types';
|
||||
import {
|
||||
listTripPhotos,
|
||||
listTripAlbumLinks,
|
||||
createTripAlbumLink,
|
||||
removeAlbumLink,
|
||||
addTripPhotos,
|
||||
removeTripPhoto,
|
||||
setTripPhotoSharing,
|
||||
} from '../../services/memories/unifiedService';
|
||||
import immichRouter from './immich';
|
||||
import synologyRouter from './synology';
|
||||
import { Selection } from '../../services/memories/helpersService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/immich', immichRouter);
|
||||
router.use('/synologyphotos', synologyRouter);
|
||||
|
||||
//------------------------------------------------
|
||||
// routes for managing photos linked to trip
|
||||
|
||||
router.get('/unified/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = listTripPhotos(tripId, authReq.user.id);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ photos: result.data });
|
||||
});
|
||||
|
||||
router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const sid = req.headers['x-socket-id'] as string;
|
||||
const selections: Selection[] = Array.isArray(req.body?.selections) ? req.body.selections : [];
|
||||
|
||||
const shared = req.body?.shared === undefined ? true : !!req.body?.shared;
|
||||
const result = await addTripPhotos(
|
||||
tripId,
|
||||
authReq.user.id,
|
||||
shared,
|
||||
selections,
|
||||
sid,
|
||||
);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
|
||||
res.json({ success: true, added: result.data.added });
|
||||
});
|
||||
|
||||
router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = await setTripPhotoSharing(
|
||||
tripId,
|
||||
authReq.user.id,
|
||||
Number(req.body?.photo_id),
|
||||
req.body?.shared,
|
||||
);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id));
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
//------------------------------
|
||||
// routes for managing album links
|
||||
|
||||
router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = listTripAlbumLinks(tripId, authReq.user.id);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ links: result.data });
|
||||
});
|
||||
|
||||
router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined;
|
||||
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name, passphrase);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/unified/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
const result = removeAlbumLink(tripId, linkId, authReq.user.id);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
export default router;
|
||||
@@ -1,153 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markRead,
|
||||
markUnread,
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
deleteAll,
|
||||
respondToBoolean,
|
||||
} from '../services/inAppNotifications';
|
||||
import { getPreferencesMatrix, setPreferences } from '../services/notificationPreferencesService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
|
||||
});
|
||||
|
||||
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
setPreferences(authReq.user.id, req.body);
|
||||
res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user'));
|
||||
});
|
||||
|
||||
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;
|
||||
res.json(await testSmtp(email || authReq.user.email));
|
||||
});
|
||||
|
||||
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
let { url } = req.body;
|
||||
if (!url || url === '••••••••') {
|
||||
url = getUserWebhookUrl(authReq.user.id);
|
||||
if (!url && authReq.user.role === 'admin') url = getAdminWebhookUrl();
|
||||
if (!url) return res.status(400).json({ error: 'No webhook URL configured' });
|
||||
}
|
||||
if (typeof url !== 'string') return res.status(400).json({ error: 'url must be a string' });
|
||||
try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
||||
res.json(await testWebhook(url));
|
||||
});
|
||||
|
||||
router.post('/test-ntfy', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { topic, server, token } = req.body as { topic?: string; server?: string; token?: string };
|
||||
|
||||
// Always load saved config for fallbacks (token may be masked or absent in request)
|
||||
const userCfg = getUserNtfyConfig(authReq.user.id);
|
||||
const adminCfg = getAdminNtfyConfig();
|
||||
|
||||
const resolvedTopic = topic || userCfg?.topic || undefined;
|
||||
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
|
||||
// Reuse saved token when request sends null, empty, or the masked placeholder
|
||||
const resolvedToken = (token && token !== '••••••••')
|
||||
? token
|
||||
: (userCfg?.token ?? adminCfg.token ?? null);
|
||||
|
||||
if (!resolvedTopic) return res.status(400).json({ error: 'No ntfy topic configured' });
|
||||
|
||||
res.json(await testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken }));
|
||||
});
|
||||
|
||||
// ── In-app notifications ──────────────────────────────────────────────────────
|
||||
|
||||
// GET /in-app — list notifications (paginated)
|
||||
router.get('/in-app', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const unreadOnly = req.query.unread_only === 'true';
|
||||
|
||||
const result = getNotifications(authReq.user.id, { limit, offset, unreadOnly });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// GET /in-app/unread-count — badge count
|
||||
router.get('/in-app/unread-count', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const count = getUnreadCount(authReq.user.id);
|
||||
res.json({ count });
|
||||
});
|
||||
|
||||
// PUT /in-app/read-all — mark all read (must be before /:id routes)
|
||||
router.put('/in-app/read-all', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const count = markAllRead(authReq.user.id);
|
||||
res.json({ success: true, count });
|
||||
});
|
||||
|
||||
// DELETE /in-app/all — delete all (must be before /:id routes)
|
||||
router.delete('/in-app/all', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const count = deleteAll(authReq.user.id);
|
||||
res.json({ success: true, count });
|
||||
});
|
||||
|
||||
// PUT /in-app/:id/read — mark single read
|
||||
router.put('/in-app/:id/read', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const ok = markRead(id, authReq.user.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// PUT /in-app/:id/unread — mark single unread
|
||||
router.put('/in-app/:id/unread', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const ok = markUnread(id, authReq.user.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /in-app/:id — delete single
|
||||
router.delete('/in-app/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const ok = deleteNotification(id, authReq.user.id);
|
||||
if (!ok) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /in-app/:id/respond — respond to a boolean notification
|
||||
router.post('/in-app/:id/respond', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
|
||||
const { response } = req.body;
|
||||
if (response !== 'positive' && response !== 'negative') {
|
||||
return res.status(400).json({ error: 'response must be "positive" or "negative"' });
|
||||
}
|
||||
|
||||
const result = await respondToBoolean(id, authReq.user.id, response);
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
res.json({ success: true, notification: result.notification });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,416 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
|
||||
import { AuthRequest, OptionalAuthRequest } from '../types';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { ALL_SCOPES } from '../mcp/scopes';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import {
|
||||
validateAuthorizeRequest,
|
||||
createAuthCode,
|
||||
consumeAuthCode,
|
||||
saveConsent,
|
||||
issueTokens,
|
||||
issueClientCredentialsToken,
|
||||
refreshTokens,
|
||||
revokeToken,
|
||||
verifyPKCE,
|
||||
authenticateClient,
|
||||
listOAuthClients,
|
||||
createOAuthClient,
|
||||
deleteOAuthClient,
|
||||
rotateOAuthClientSecret,
|
||||
listOAuthSessions,
|
||||
revokeSession,
|
||||
getUserByAccessToken,
|
||||
AuthorizeParams,
|
||||
} from '../services/oauthService';
|
||||
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal in-file rate limiter (same pattern as auth.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RateEntry { count: number; first: number; }
|
||||
|
||||
function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Request) => string) {
|
||||
const store = new Map<string, RateEntry>();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, r] of store) if (now - r.first >= windowMs) store.delete(k);
|
||||
}, windowMs).unref();
|
||||
|
||||
return (req: Request, res: Response, next: () => void): void => {
|
||||
const key = keyFn(req);
|
||||
const now = Date.now();
|
||||
const record = store.get(key);
|
||||
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
|
||||
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
|
||||
return;
|
||||
}
|
||||
if (!record || now - record.first >= windowMs) {
|
||||
store.set(key, { count: 1, first: now });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
|
||||
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
|
||||
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public router: /oauth/token and /oauth/revoke
|
||||
// (/.well-known and /oauth/register are now handled by SDK in app.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const oauthPublicRouter = express.Router();
|
||||
|
||||
// Token endpoint — handles authorization_code and refresh_token grants
|
||||
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
// M1: RFC 6749 §5.1 — token responses must not be cached
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Pragma', 'no-cache');
|
||||
|
||||
// Accept both JSON and application/x-www-form-urlencoded
|
||||
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
|
||||
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!client_id) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||
}
|
||||
|
||||
// ---- authorization_code grant ----
|
||||
if (grant_type === 'authorization_code') {
|
||||
if (!code || !redirect_uri || !code_verifier) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
|
||||
}
|
||||
|
||||
const pending = consumeAuthCode(code);
|
||||
|
||||
// H5: collapse all invalid_grant cases to one message; log specifics server-side
|
||||
if (!pending) {
|
||||
writeAudit({ userId: null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'code_invalid_or_expired' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
if (pending.clientId !== client_id) {
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'client_id_mismatch' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
if (pending.redirectUri !== redirect_uri) {
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'redirect_uri_mismatch' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
// RFC 8707: if the auth code was bound to a resource, the token request must present the same value
|
||||
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) {
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'resource_mismatch' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
// Verify client secret
|
||||
if (!authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
}
|
||||
|
||||
// Verify PKCE
|
||||
if (!verifyPKCE(code_verifier, pending.codeChallenge)) {
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'pkce_failed' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
const tokens = issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
|
||||
return res.json(tokens);
|
||||
}
|
||||
|
||||
// ---- refresh_token grant ----
|
||||
if (grant_type === 'refresh_token') {
|
||||
if (!refresh_token) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
|
||||
}
|
||||
|
||||
const result = refreshTokens(refresh_token, client_id, client_secret, ip);
|
||||
if (result.error) {
|
||||
if (result.error === 'invalid_client') {
|
||||
logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
}
|
||||
return res.status(result.status || 400).json({
|
||||
error: result.error,
|
||||
error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result.tokens);
|
||||
}
|
||||
|
||||
// ---- client_credentials grant ----
|
||||
if (grant_type === 'client_credentials') {
|
||||
if (!client_secret) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
|
||||
}
|
||||
|
||||
const client = authenticateClient(client_id, client_secret);
|
||||
if (!client) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
}
|
||||
|
||||
// Public clients and DCR-anonymous clients are ineligible for client_credentials.
|
||||
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
|
||||
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
|
||||
return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
|
||||
}
|
||||
|
||||
// Scope: use requested subset or fall back to all allowed scopes.
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
let grantedScopes: string[];
|
||||
if (body.scope) {
|
||||
const requested = body.scope.split(' ').filter(Boolean);
|
||||
const invalid = requested.filter(s => !allowedScopes.includes(s));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
|
||||
}
|
||||
grantedScopes = requested;
|
||||
} else {
|
||||
grantedScopes = allowedScopes;
|
||||
}
|
||||
|
||||
// Audience: honour RFC 8707 resource param; default to the MCP endpoint so the
|
||||
// token passes audience binding in mcp/index.ts without extra configuration.
|
||||
const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
|
||||
const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
|
||||
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
|
||||
return res.json(tokens);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
||||
});
|
||||
|
||||
// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3)
|
||||
// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming.
|
||||
oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
const auth = req.headers['authorization'];
|
||||
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
const token = auth.slice(7);
|
||||
const info = getUserByAccessToken(token);
|
||||
if (!info) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
return res.json({
|
||||
sub: String(info.user.id),
|
||||
email: info.user.email,
|
||||
email_verified: true,
|
||||
preferred_username: info.user.username,
|
||||
});
|
||||
});
|
||||
|
||||
// Token revocation endpoint (RFC 7009)
|
||||
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
|
||||
const { token, client_id, client_secret } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!token || !client_id) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
|
||||
}
|
||||
|
||||
if (!authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
}
|
||||
|
||||
revokeToken(token, client_id, undefined, ip);
|
||||
// RFC 7009 §2.2: always respond 200 even if token was already revoked or not found
|
||||
return res.status(200).json({});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API router: /api/oauth/* — authenticated endpoints used by the SPA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const oauthApiRouter = express.Router();
|
||||
|
||||
// SPA calls this on page load to validate OAuth params before rendering consent UI
|
||||
oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => {
|
||||
// M2 / H3: gate by addon; 404 prevents feature fingerprinting for anonymous callers
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const params = req.query as Partial<AuthorizeParams>;
|
||||
const userId = (req as OptionalAuthRequest).user?.id ?? null;
|
||||
|
||||
const result = validateAuthorizeRequest(
|
||||
{
|
||||
response_type: params.response_type || '',
|
||||
client_id: params.client_id || '',
|
||||
redirect_uri: params.redirect_uri || '',
|
||||
scope: params.scope || '',
|
||||
state: params.state,
|
||||
code_challenge: params.code_challenge || '',
|
||||
code_challenge_method: params.code_challenge_method || '',
|
||||
resource: typeof params.resource === 'string' ? params.resource : undefined,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
|
||||
// (validateAuthorizeRequest already does this, but be explicit here)
|
||||
if (userId === null && result.valid) {
|
||||
return res.json({ valid: result.valid, loginRequired: true });
|
||||
}
|
||||
|
||||
// For unauthenticated error cases return a generic error to prevent oracle enumeration
|
||||
if (userId === null && !result.valid) {
|
||||
return res.json({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' });
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
// User submits consent (approve or deny) — requires cookie-only auth (M7)
|
||||
oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Response) => {
|
||||
const { user } = req as AuthRequest;
|
||||
const {
|
||||
client_id, redirect_uri, scope, state,
|
||||
code_challenge, code_challenge_method, approved, resource,
|
||||
} = req.body as {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
approved: boolean;
|
||||
resource?: string;
|
||||
};
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
}
|
||||
|
||||
if (!approved) {
|
||||
// User denied — redirect with error
|
||||
const url = new URL(redirect_uri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
url.searchParams.set('error_description', 'User denied the authorization request');
|
||||
if (state) url.searchParams.set('state', state);
|
||||
return res.json({ redirect: url.toString() });
|
||||
}
|
||||
|
||||
// Re-validate all params (server-side re-check after user action)
|
||||
const params: AuthorizeParams = {
|
||||
response_type: 'code',
|
||||
client_id,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
resource,
|
||||
};
|
||||
|
||||
const validation = validateAuthorizeRequest(params, user.id);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error, error_description: validation.error_description });
|
||||
}
|
||||
|
||||
const scopes = validation.scopes!;
|
||||
|
||||
// Store consent (union with any existing scopes)
|
||||
saveConsent(client_id, user.id, scopes, ip);
|
||||
|
||||
// Issue auth code
|
||||
const code = createAuthCode({
|
||||
clientId: client_id,
|
||||
userId: user.id,
|
||||
redirectUri: redirect_uri,
|
||||
scopes,
|
||||
resource: validation.resource ?? null,
|
||||
codeChallenge: code_challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
return res.status(503).json({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' });
|
||||
}
|
||||
|
||||
const url = new URL(redirect_uri);
|
||||
url.searchParams.set('code', code);
|
||||
if (state) url.searchParams.set('state', state);
|
||||
|
||||
return res.json({ redirect: url.toString() });
|
||||
});
|
||||
|
||||
// ---- OAuth client CRUD ----
|
||||
|
||||
oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
return res.json({ clients: listOAuthClients(user.id) });
|
||||
});
|
||||
|
||||
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as {
|
||||
name: string;
|
||||
redirect_uris?: string[];
|
||||
allowed_scopes: string[];
|
||||
allows_client_credentials?: boolean;
|
||||
};
|
||||
|
||||
const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.status(201).json(result);
|
||||
});
|
||||
|
||||
oauthApiRouter.post('/clients/:id/rotate', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const result = rotateOAuthClientSecret(user.id, req.params.id, getClientIp(req));
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.json({ client_secret: result.client_secret });
|
||||
});
|
||||
|
||||
oauthApiRouter.delete('/clients/:id', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const result = deleteOAuthClient(user.id, req.params.id, getClientIp(req));
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// ---- Active OAuth sessions ----
|
||||
|
||||
oauthApiRouter.get('/sessions', authenticate, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
return res.json({ sessions: listOAuthSessions(user.id) });
|
||||
});
|
||||
|
||||
oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.json({ success: true });
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { setAuthCookie } from '../services/cookie';
|
||||
import {
|
||||
getOidcConfig,
|
||||
discover,
|
||||
createState,
|
||||
consumeState,
|
||||
createAuthCode,
|
||||
consumeAuthCode,
|
||||
exchangeCodeForToken,
|
||||
getUserInfo,
|
||||
verifyIdToken,
|
||||
findOrCreateUser,
|
||||
touchLastLogin,
|
||||
generateToken,
|
||||
frontendUrl,
|
||||
} from '../services/oidcService';
|
||||
import { getAppUrl } from '../services/notifications';
|
||||
import { resolveAuthToggles } from '../services/authService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---- GET /login ----------------------------------------------------------
|
||||
|
||||
router.get('/login', async (req: Request, res: Response) => {
|
||||
if (!resolveAuthToggles().oidc_login) {
|
||||
return res.status(403).json({ error: 'SSO login is disabled.' });
|
||||
}
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
|
||||
|
||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
return res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||
const appUrl = getAppUrl();
|
||||
if (!appUrl) {
|
||||
return res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
||||
}
|
||||
|
||||
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
||||
const inviteToken = req.query.invite as string | undefined;
|
||||
const { state, codeChallenge } = createState(redirectUri, inviteToken);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: process.env.OIDC_SCOPE || 'openid email profile',
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
res.redirect(`${doc.authorization_endpoint}?${params}`);
|
||||
} catch (err: unknown) {
|
||||
console.error('[OIDC] Login error:', err instanceof Error ? err.message : err);
|
||||
res.status(500).json({ error: 'OIDC login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- GET /callback -------------------------------------------------------
|
||||
|
||||
router.get('/callback', async (req: Request, res: Response) => {
|
||||
if (!resolveAuthToggles().oidc_login) {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=sso_disabled'));
|
||||
}
|
||||
|
||||
const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string };
|
||||
|
||||
if (oidcError) {
|
||||
console.error('[OIDC] Provider error:', oidcError);
|
||||
return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError)));
|
||||
}
|
||||
if (!code || !state) {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=missing_params'));
|
||||
}
|
||||
|
||||
const pending = consumeState(state);
|
||||
if (!pending) {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=invalid_state'));
|
||||
}
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
|
||||
|
||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=issuer_not_https'));
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||
|
||||
const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret, pending.codeVerifier);
|
||||
if (!tokenData._ok || !tokenData.access_token) {
|
||||
console.error('[OIDC] Token exchange failed: status', tokenData._status);
|
||||
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
|
||||
}
|
||||
|
||||
// Strict id_token verification: signature via JWKS + iss + aud.
|
||||
// Previously only the access_token was used to hit userinfo, so a
|
||||
// compromised provider or MITM could supply a crafted userinfo
|
||||
// response the server would blindly trust. When the id_token is
|
||||
// missing from the token response (non-compliant provider) we still
|
||||
// reject — an Authorization Code flow MUST return one per OIDC Core.
|
||||
if (!tokenData.id_token) {
|
||||
console.error('[OIDC] Token response missing id_token — refusing login');
|
||||
return res.redirect(frontendUrl('/login?oidc_error=no_id_token'));
|
||||
}
|
||||
const idVerify = await verifyIdToken(
|
||||
tokenData.id_token,
|
||||
doc,
|
||||
config.clientId,
|
||||
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
|
||||
);
|
||||
if (idVerify.ok !== true) {
|
||||
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
||||
console.error('[OIDC] id_token verification failed:', reason);
|
||||
return res.redirect(frontendUrl('/login?oidc_error=id_token_invalid'));
|
||||
}
|
||||
|
||||
const userInfo = await getUserInfo(doc.userinfo_endpoint, tokenData.access_token);
|
||||
if (!userInfo.email) {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
|
||||
}
|
||||
// Cross-check: the userinfo response must be for the same subject
|
||||
// the id_token signed. Catches a compromised userinfo endpoint that
|
||||
// speaks for a different principal than the id_token's claim.
|
||||
const tokenSub = idVerify.claims.sub;
|
||||
if (typeof tokenSub === 'string' && userInfo.sub && userInfo.sub !== tokenSub) {
|
||||
console.error('[OIDC] userinfo.sub does not match id_token.sub — refusing login');
|
||||
return res.redirect(frontendUrl('/login?oidc_error=subject_mismatch'));
|
||||
}
|
||||
|
||||
const result = findOrCreateUser(userInfo, config, pending.inviteToken);
|
||||
if ('error' in result) {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=' + result.error));
|
||||
}
|
||||
|
||||
touchLastLogin(result.user.id);
|
||||
const jwtToken = generateToken(result.user);
|
||||
const authCode = createAuthCode(jwtToken);
|
||||
res.redirect(frontendUrl('/login?oidc_code=' + authCode));
|
||||
} catch (err: unknown) {
|
||||
console.error('[OIDC] Callback error:', err);
|
||||
res.redirect(frontendUrl('/login?oidc_error=server_error'));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- GET /exchange -------------------------------------------------------
|
||||
|
||||
router.get('/exchange', (req: Request, res: Response) => {
|
||||
const { code } = req.query as { code?: string };
|
||||
if (!code) return res.status(400).json({ error: 'Code required' });
|
||||
|
||||
const result = consumeAuthCode(code);
|
||||
if ('error' in result) return res.status(400).json({ error: result.error });
|
||||
|
||||
setAuthCookie(res, result.token, req);
|
||||
res.json({ token: result.token });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,271 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
verifyTripAccess,
|
||||
listItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
bulkImport,
|
||||
listBags,
|
||||
createBag,
|
||||
updateBag,
|
||||
deleteBag,
|
||||
applyTemplate,
|
||||
saveAsTemplate,
|
||||
setBagMembers,
|
||||
getCategoryAssignees,
|
||||
updateCategoryAssignees,
|
||||
reorderItems,
|
||||
} from '../services/packingService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const items = listItems(tripId);
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
// Bulk import packing items (must be before /:id)
|
||||
router.post('/import', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { items } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
|
||||
|
||||
const created = bulkImport(tripId, items);
|
||||
|
||||
res.status(201).json({ items: created, count: created.length });
|
||||
for (const item of created) {
|
||||
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { name, category, checked } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!name) return res.status(400).json({ error: 'Item name is required' });
|
||||
|
||||
const item = createItem(tripId, { name, category, checked });
|
||||
res.status(201).json({ item });
|
||||
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
reorderItems(tripId, orderedIds);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { name, checked, category, weight_grams, bag_id, quantity } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const updated = updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity }, Object.keys(req.body));
|
||||
if (!updated) return res.status(404).json({ error: 'Item not found' });
|
||||
|
||||
res.json({ item: updated });
|
||||
broadcast(tripId, 'packing:updated', { item: updated }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' });
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ── Bags CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/bags', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
const bags = listBags(tripId);
|
||||
res.json({ bags });
|
||||
});
|
||||
|
||||
router.post('/bags', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { name, color } = req.body;
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||
const bag = createBag(tripId, { name, color });
|
||||
res.status(201).json({ bag });
|
||||
broadcast(tripId, 'packing:bag-created', { bag }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, bagId } = req.params;
|
||||
const { name, color, weight_limit_grams, user_id } = req.body;
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
const updated = updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(req.body));
|
||||
if (!updated) return res.status(404).json({ error: 'Bag not found' });
|
||||
res.json({ bag: updated });
|
||||
broadcast(tripId, 'packing:bag-updated', { bag: updated }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, bagId } = req.params;
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!deleteBag(tripId, bagId)) return res.status(404).json({ error: 'Bag not found' });
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ── Apply template ──────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/apply-template/:templateId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, templateId } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const added = applyTemplate(tripId, templateId);
|
||||
if (!added) return res.status(404).json({ error: 'Template not found or empty' });
|
||||
|
||||
res.json({ items: added, count: added.length });
|
||||
broadcast(tripId, 'packing:template-applied', { items: added }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ── Bag Members ────────────────────────────────────────────────────────────
|
||||
|
||||
router.put('/bags/:bagId/members', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, bagId } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
const members = setBagMembers(tripId, bagId, Array.isArray(user_ids) ? user_ids : []);
|
||||
if (!members) return res.status(404).json({ error: 'Bag not found' });
|
||||
res.json({ members });
|
||||
broadcast(tripId, 'packing:bag-members-updated', { bagId: Number(bagId), members }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ── Save as Template ───────────────────────────────────────────────────────
|
||||
|
||||
router.post('/save-as-template', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Template name is required' });
|
||||
|
||||
const template = saveAsTemplate(tripId, authReq.user.id, name.trim());
|
||||
if (!template) return res.status(400).json({ error: 'No items to save' });
|
||||
|
||||
res.status(201).json({ template });
|
||||
});
|
||||
|
||||
// ── Category assignees ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/category-assignees', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const assignees = getCategoryAssignees(tripId);
|
||||
res.json({ assignees });
|
||||
});
|
||||
|
||||
router.put('/category-assignees/:categoryName', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, categoryName } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const cat = decodeURIComponent(categoryName);
|
||||
const rows = updateCategoryAssignees(tripId, cat, user_ids);
|
||||
|
||||
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/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
// Use trip scope so the service resolves recipients — actor is excluded automatically
|
||||
send({ event: 'packing_tagged', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat, tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,47 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService';
|
||||
import { canAccessTrekPhoto } from '../services/memories/helpersService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:id/thumbnail', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photoId = Number(req.params.id);
|
||||
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
|
||||
|
||||
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await streamPhoto(res, authReq.user.id, photoId, 'thumbnail');
|
||||
});
|
||||
|
||||
router.get('/:id/original', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photoId = Number(req.params.id);
|
||||
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
|
||||
|
||||
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await streamPhoto(res, authReq.user.id, photoId, 'original');
|
||||
});
|
||||
|
||||
router.get('/:id/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photoId = Number(req.params.id);
|
||||
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
|
||||
|
||||
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const result = await getPhotoInfo(authReq.user.id, photoId);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,266 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
listPlaces,
|
||||
createPlace,
|
||||
getPlace,
|
||||
updatePlace,
|
||||
deletePlace,
|
||||
deletePlacesMany,
|
||||
importGpx,
|
||||
importMapFile,
|
||||
importGoogleList,
|
||||
importNaverList,
|
||||
searchPlaceImage,
|
||||
type KmlImportOptions,
|
||||
} from '../services/placeService';
|
||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
||||
|
||||
const uploadMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId } = req.params;
|
||||
const { search, category, tag } = req.query;
|
||||
|
||||
const places = listPlaces(tripId, {
|
||||
search: search as string | undefined,
|
||||
category: category as string | undefined,
|
||||
tag: tag as string | undefined,
|
||||
});
|
||||
|
||||
res.json({ places });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Place name is required' });
|
||||
}
|
||||
|
||||
const place = createPlace(tripId, req.body);
|
||||
res.status(201).json({ place });
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
try { onPlaceCreated(Number(tripId), place.id); } catch {}
|
||||
});
|
||||
|
||||
// Import places from GPX file with full track geometry (must be before /:id)
|
||||
router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single('file'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
const importWaypoints = parseBool(req.body.importWaypoints, true);
|
||||
const importRoutes = parseBool(req.body.importRoutes, true);
|
||||
const importTracks = parseBool(req.body.importTracks, true);
|
||||
|
||||
if (!importWaypoints && !importRoutes && !importTracks) {
|
||||
return res.status(400).json({ error: 'No import types selected' });
|
||||
}
|
||||
|
||||
const result = importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks });
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: 'No matching places found in GPX file' });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single('file'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
const { tripId } = req.params;
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
const importPoints = parseBool(req.body.importPoints, true);
|
||||
const importPaths = parseBool(req.body.importPaths, true);
|
||||
|
||||
if (!importPoints && !importPaths) {
|
||||
return res.status(400).json({ error: 'No import types selected' });
|
||||
}
|
||||
|
||||
const kmlOpts: KmlImportOptions = { importPoints, importPaths };
|
||||
|
||||
try {
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname, kmlOpts);
|
||||
if (result.summary?.totalPlacemarks === 0) {
|
||||
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
|
||||
}
|
||||
|
||||
res.status(201).json(result);
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to import map file';
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Import places from a shared Google Maps list URL
|
||||
router.post('/import/google-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
const result = await importGoogleList(tripId, url);
|
||||
|
||||
if ('error' in result) {
|
||||
return res.status(result.status).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('[Places] Google list import error:', err instanceof Error ? err.message : err);
|
||||
res.status(400).json({ error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Import places from a shared Naver Maps list URL
|
||||
router.post('/import/naver-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
const { tripId } = req.params;
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
const result = await importNaverList(tripId, url);
|
||||
|
||||
if ('error' in result) {
|
||||
return res.status(result.status).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('[Places] Naver list import error:', err instanceof Error ? err.message : err);
|
||||
res.status(400).json({ error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const place = getPlace(tripId, id);
|
||||
if (!place) {
|
||||
return res.status(404).json({ error: 'Place not found' });
|
||||
}
|
||||
|
||||
res.json({ place });
|
||||
});
|
||||
|
||||
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await searchPlaceImage(tripId, id, authReq.user.id);
|
||||
|
||||
if ('error' in result) {
|
||||
return res.status(result.status).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ photos: result.photos });
|
||||
} catch (err: unknown) {
|
||||
console.error('Unsplash error:', err);
|
||||
res.status(500).json({ error: 'Error searching for image' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const place = updatePlace(tripId, id, req.body);
|
||||
if (!place) {
|
||||
return res.status(404).json({ error: 'Place not found' });
|
||||
}
|
||||
|
||||
res.json({ place });
|
||||
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
|
||||
try { onPlaceUpdated(place.id); } catch {}
|
||||
});
|
||||
|
||||
// Bulk delete (must be before /:id)
|
||||
router.post('/bulk-delete', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { ids } = req.body as { ids?: unknown };
|
||||
if (!Array.isArray(ids) || ids.some(v => typeof v !== 'number'))
|
||||
return res.status(400).json({ error: 'ids must be an array of numbers' });
|
||||
|
||||
const idList = ids as number[];
|
||||
if (idList.length === 0) return res.json({ deleted: [], count: 0 });
|
||||
|
||||
for (const id of idList) { try { onPlaceDeleted(id); } catch {} }
|
||||
const deleted = deletePlacesMany(tripId, idList);
|
||||
|
||||
res.json({ deleted, count: deleted.length });
|
||||
const socketId = req.headers['x-socket-id'] as string;
|
||||
for (const id of deleted) {
|
||||
broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
try { onPlaceDeleted(Number(id)); } catch {} // sync before actual delete
|
||||
const deleted = deletePlace(tripId, id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Place not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'place:deleted', { placeId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,10 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { DEFAULT_LANGUAGE } from '../config';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.json({ defaultLanguage: DEFAULT_LANGUAGE });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,199 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
verifyTripAccess,
|
||||
listReservations,
|
||||
createReservation,
|
||||
updatePositions,
|
||||
getReservation,
|
||||
updateReservation,
|
||||
deleteReservation,
|
||||
} from '../services/reservationService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const reservations = listReservations(tripId);
|
||||
res.json({ reservations });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
});
|
||||
|
||||
if (accommodationCreated) {
|
||||
broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
// Auto-create budget entry if price was provided
|
||||
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
||||
try {
|
||||
const budgetItem = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: create_budget_entry.category || type || 'Other',
|
||||
total_price: create_budget_entry.total_price,
|
||||
});
|
||||
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
|
||||
} catch (err) {
|
||||
console.error('[reservations] Failed to create budget entry:', err);
|
||||
}
|
||||
}
|
||||
|
||||
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/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
// Batch update day_plan_position for multiple reservations (must be before /:id)
|
||||
router.put('/positions', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { positions } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
|
||||
|
||||
const { day_id } = req.body;
|
||||
updatePositions(tripId, positions, day_id);
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:positions', { positions, day_id }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const current = getReservation(id, tripId);
|
||||
if (!current) return res.status(404).json({ error: 'Reservation not found' });
|
||||
|
||||
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
}, current);
|
||||
|
||||
if (accommodationChanged) {
|
||||
broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
// Remove linked budget entry if price was cleared
|
||||
if (!create_budget_entry || !create_budget_entry.total_price) {
|
||||
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (linked) {
|
||||
deleteBudgetItem(linked.id, tripId);
|
||||
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-create or update budget entry if price was provided
|
||||
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
||||
try {
|
||||
const itemName = title || current.title;
|
||||
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
const updated = updateBudgetItem(existing.id, tripId, {
|
||||
name: itemName,
|
||||
category: create_budget_entry.category || type || current.type || 'Other',
|
||||
total_price: create_budget_entry.total_price,
|
||||
});
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string);
|
||||
} else {
|
||||
const budgetItem = createBudgetItem(tripId, {
|
||||
name: itemName,
|
||||
category: create_budget_entry.category || type || current.type || 'Other',
|
||||
total_price: create_budget_entry.total_price,
|
||||
});
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, budgetItem.id);
|
||||
budgetItem.reservation_id = Number(id);
|
||||
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[reservations] Failed to create/update budget entry:', err);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ reservation });
|
||||
broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { deleted: reservation, accommodationDeleted, deletedBudgetItemId } = deleteReservation(id, tripId);
|
||||
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
|
||||
|
||||
if (accommodationDeleted) {
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
if (deletedBudgetItemId) {
|
||||
broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking', tripId: String(tripId) } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,36 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as settingsService from '../services/settingsService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ settings: settingsService.getUserSettings(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.put('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { key, value } = req.body;
|
||||
if (!key) return res.status(400).json({ error: 'Key is required' });
|
||||
if (value === '••••••••') return res.json({ success: true, key, unchanged: true });
|
||||
settingsService.upsertSetting(authReq.user.id, key, value);
|
||||
res.json({ success: true, key, value });
|
||||
});
|
||||
|
||||
router.post('/bulk', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { settings } = req.body;
|
||||
if (!settings || typeof settings !== 'object')
|
||||
return res.status(400).json({ error: 'Settings object is required' });
|
||||
try {
|
||||
const updated = settingsService.bulkUpsertSettings(authReq.user.id, settings);
|
||||
res.json({ success: true, updated });
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
res.status(500).json({ error: 'Error saving settings' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,61 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { canAccessTrip } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as shareService from '../services/shareService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create a share link for a trip (owner/member only)
|
||||
router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const access = canAccessTrip(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { share_map, share_bookings, share_packing, share_budget, share_collab } = req.body || {};
|
||||
const result = shareService.createOrUpdateShareLink(tripId, authReq.user.id, {
|
||||
share_map, share_bookings, share_packing, share_budget, share_collab,
|
||||
});
|
||||
|
||||
if (result.created) {
|
||||
return res.status(201).json({ token: result.token });
|
||||
}
|
||||
return res.json({ token: result.token });
|
||||
});
|
||||
|
||||
// Get share link status
|
||||
router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const info = shareService.getShareLink(tripId);
|
||||
res.json(info ? info : { token: null });
|
||||
});
|
||||
|
||||
// Delete share link
|
||||
router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const access = canAccessTrip(tripId, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
shareService.deleteShareLink(tripId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Public read-only trip data (no auth required)
|
||||
router.get('/shared/:token', (req: Request, res: Response) => {
|
||||
const { token } = req.params;
|
||||
const data = shareService.getSharedTripData(token);
|
||||
if (!data) return res.status(404).json({ error: 'Invalid or expired link' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { getActiveNoticesFor, dismissNotice } from '../systemNotices/service.js';
|
||||
import type { AuthRequest } from '../types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/system-notices/active
|
||||
// Returns notices active for the authenticated user.
|
||||
router.get('/active', authenticate, (req, res) => {
|
||||
const userId = (req as AuthRequest).user!.id;
|
||||
const notices = getActiveNoticesFor(userId);
|
||||
res.json(notices);
|
||||
});
|
||||
|
||||
// POST /api/system-notices/:id/dismiss
|
||||
// Marks a notice as dismissed for the authenticated user. Idempotent.
|
||||
router.post('/:id/dismiss', authenticate, (req, res) => {
|
||||
const userId = (req as AuthRequest).user!.id;
|
||||
const noticeId = req.params.id;
|
||||
const ok = dismissNotice(userId, noticeId);
|
||||
if (!ok) {
|
||||
res.status(404).json({ error: 'NOTICE_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,38 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as tagService from '../services/tagService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ tags: tagService.listTags(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { name, color } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Tag name is required' });
|
||||
const tag = tagService.createTag(authReq.user.id, name, color);
|
||||
res.status(201).json({ tag });
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { name, color } = req.body;
|
||||
if (!tagService.getTagByIdAndUser(req.params.id, authReq.user.id))
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
const tag = tagService.updateTag(req.params.id, name, color);
|
||||
res.json({ tag });
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!tagService.getTagByIdAndUser(req.params.id, authReq.user.id))
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
tagService.deleteTag(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,127 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
verifyTripAccess,
|
||||
listItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
getCategoryAssignees,
|
||||
updateCategoryAssignees,
|
||||
reorderItems,
|
||||
} from '../services/todoService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const items = listItems(tripId);
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { name, category, due_date, description, assigned_user_id, priority } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!name) return res.status(400).json({ error: 'Item name is required' });
|
||||
|
||||
const item = createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
res.status(201).json({ item });
|
||||
broadcast(tripId, 'todo:created', { item }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/reorder', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
reorderItems(tripId, orderedIds);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { name, checked, category, due_date, description, assigned_user_id, priority } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const updated = updateItem(tripId, id, { name, checked, category, due_date, description, assigned_user_id, priority }, Object.keys(req.body));
|
||||
if (!updated) return res.status(404).json({ error: 'Item not found' });
|
||||
|
||||
res.json({ item: updated });
|
||||
broadcast(tripId, 'todo:updated', { item: updated }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' });
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'todo:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ── Category assignees ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/category-assignees', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const assignees = getCategoryAssignees(tripId);
|
||||
res.json({ assignees });
|
||||
});
|
||||
|
||||
router.put('/category-assignees/:categoryName', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, categoryName } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const cat = decodeURIComponent(categoryName);
|
||||
const rows = updateCategoryAssignees(tripId, cat, user_ids);
|
||||
|
||||
res.json({ assignees: rows });
|
||||
broadcast(tripId, 'todo:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,358 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { AuthRequest, Trip } from '../types';
|
||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import {
|
||||
listTrips,
|
||||
createTrip,
|
||||
getTrip,
|
||||
updateTrip,
|
||||
deleteTrip,
|
||||
getTripRaw,
|
||||
getTripOwner,
|
||||
deleteOldCover,
|
||||
updateCoverImage,
|
||||
listMembers,
|
||||
addMember,
|
||||
removeMember,
|
||||
exportICS,
|
||||
copyTripById,
|
||||
verifyTripAccess,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
TRIP_SELECT,
|
||||
} from '../services/tripService';
|
||||
import { listDays, listAccommodations } from '../services/dayService';
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listItems as listPackingItems } from '../services/packingService';
|
||||
import { listItems as listTodoItems } from '../services/todoService';
|
||||
import { listBudgetItems } from '../services/budgetService';
|
||||
import { listReservations } from '../services/reservationService';
|
||||
import { listFiles } from '../services/fileService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
const coversDir = path.join(__dirname, '../../uploads/covers');
|
||||
const coverStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true });
|
||||
cb(null, coversDir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
},
|
||||
});
|
||||
const uploadCover = multer({
|
||||
storage: coverStorage,
|
||||
limits: { fileSize: MAX_COVER_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only jpg, png, gif, webp images allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── List trips ────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const archived = req.query.archived === '1' ? 1 : 0;
|
||||
const trips = listTrips(authReq.user.id, archived);
|
||||
res.json({ trips });
|
||||
});
|
||||
|
||||
// ── Create trip ───────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
|
||||
return res.status(403).json({ error: 'No permission to create trips' });
|
||||
|
||||
const { title, description, currency, reminder_days, day_count } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
|
||||
const toDateStr = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.getDate() + n); return r; };
|
||||
|
||||
let start_date: string | null = req.body.start_date || null;
|
||||
let end_date: string | null = req.body.end_date || null;
|
||||
|
||||
if (!start_date && !end_date) {
|
||||
// No dates: create dateless placeholder days (day_count or default 7)
|
||||
} else if (start_date && !end_date) {
|
||||
end_date = toDateStr(addDays(new Date(start_date), 6));
|
||||
} else if (!start_date && end_date) {
|
||||
start_date = toDateStr(addDays(new Date(end_date), -6));
|
||||
}
|
||||
|
||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
||||
return res.status(400).json({ error: 'End date must be after start date' });
|
||||
|
||||
const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), 365) : undefined;
|
||||
const { trip, tripId, reminderDays } = createTrip(authReq.user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount });
|
||||
|
||||
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } });
|
||||
if (reminderDays > 0) {
|
||||
logInfo(`${authReq.user.email} set ${reminderDays}-day reminder for trip "${title}"`);
|
||||
}
|
||||
|
||||
res.status(201).json({ trip });
|
||||
});
|
||||
|
||||
// ── Get trip ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const trip = getTrip(req.params.id, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
res.json({ trip });
|
||||
});
|
||||
|
||||
// ── Update trip ───────────────────────────────────────────────────────────
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const tripOwnerId = access.user_id;
|
||||
const isMember = access.user_id !== authReq.user.id;
|
||||
|
||||
// Archive check
|
||||
if (req.body.is_archived !== undefined) {
|
||||
if (!checkPermission('trip_archive', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
||||
return res.status(403).json({ error: 'No permission to archive/unarchive this trip' });
|
||||
}
|
||||
// Cover image check
|
||||
if (req.body.cover_image !== undefined) {
|
||||
if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
||||
return res.status(403).json({ error: 'No permission to change cover image' });
|
||||
}
|
||||
// General edit check (title, description, dates, currency, reminder_days)
|
||||
const editFields = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days', 'day_count'];
|
||||
if (editFields.some(f => req.body[f] !== undefined)) {
|
||||
if (!checkPermission('trip_edit', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
||||
return res.status(403).json({ error: 'No permission to edit this trip' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = updateTrip(req.params.id, authReq.user.id, req.body, authReq.user.role);
|
||||
|
||||
if (Object.keys(result.changes).length > 0) {
|
||||
writeAudit({ userId: authReq.user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(req.params.id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } });
|
||||
if (result.isAdminEdit && result.ownerEmail) {
|
||||
logInfo(`Admin ${authReq.user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.newReminder !== result.oldReminder) {
|
||||
if (result.newReminder > 0) {
|
||||
logInfo(`${authReq.user.email} set ${result.newReminder}-day reminder for trip "${result.newTitle}"`);
|
||||
} else {
|
||||
logInfo(`${authReq.user.email} removed reminder for trip "${result.newTitle}"`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ trip: result.updatedTrip });
|
||||
broadcast(req.params.id, 'trip:updated', { trip: result.updatedTrip }, req.headers['x-socket-id'] as string);
|
||||
} catch (e: any) {
|
||||
if (e instanceof NotFoundError) return res.status(404).json({ error: e.message });
|
||||
if (e instanceof ValidationError) return res.status(400).json({ error: e.message });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cover upload ──────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||
const tripOwnerId = access?.user_id;
|
||||
if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' });
|
||||
const isMember = tripOwnerId !== authReq.user.id;
|
||||
if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
||||
return res.status(403).json({ error: 'No permission to change the cover image' });
|
||||
|
||||
const trip = getTripRaw(req.params.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
||||
|
||||
deleteOldCover(trip.cover_image);
|
||||
|
||||
const coverUrl = `/uploads/covers/${req.file.filename}`;
|
||||
updateCoverImage(req.params.id, coverUrl);
|
||||
res.json({ cover_image: coverUrl });
|
||||
});
|
||||
|
||||
// ── Copy / duplicate a trip ──────────────────────────────────────────────────
|
||||
router.post('/:id/copy', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
|
||||
return res.status(403).json({ error: 'No permission to create trips' });
|
||||
|
||||
if (!canAccessTrip(req.params.id, authReq.user.id))
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
try {
|
||||
const newTripId = copyTripById(req.params.id, authReq.user.id, req.body.title);
|
||||
writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId, title: req.body.title } });
|
||||
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: newTripId });
|
||||
res.status(201).json({ trip });
|
||||
} catch {
|
||||
return res.status(500).json({ error: 'Failed to copy trip' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const tripOwner = getTripOwner(req.params.id);
|
||||
if (!tripOwner) return res.status(404).json({ error: 'Trip not found' });
|
||||
const tripOwnerId = tripOwner.user_id;
|
||||
const isMemberDel = tripOwnerId !== authReq.user.id;
|
||||
if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, isMemberDel))
|
||||
return res.status(403).json({ error: 'No permission to delete this trip' });
|
||||
|
||||
const info = deleteTrip(req.params.id, authReq.user.id, authReq.user.role);
|
||||
|
||||
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } });
|
||||
if (info.isAdminDelete && info.ownerEmail) {
|
||||
logInfo(`Admin ${authReq.user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(info.tripId, 'trip:deleted', { id: info.tripId }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ── List members ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||
if (!access)
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const { owner, members } = listMembers(req.params.id, access.user_id);
|
||||
res.json({ owner, members, current_user_id: authReq.user.id });
|
||||
});
|
||||
|
||||
// ── Add member ────────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||
if (!access)
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const tripOwnerId = access.user_id;
|
||||
const isMember = tripOwnerId !== authReq.user.id;
|
||||
if (!checkPermission('member_manage', authReq.user.role, tripOwnerId, authReq.user.id, isMember))
|
||||
return res.status(403).json({ error: 'No permission to manage members' });
|
||||
|
||||
const { identifier } = req.body;
|
||||
|
||||
try {
|
||||
const result = addMember(req.params.id, identifier, tripOwnerId, authReq.user.id);
|
||||
|
||||
// Notify invited user
|
||||
import('../services/notificationService').then(({ send }) => {
|
||||
send({ event: 'trip_invite', actorId: authReq.user.id, scope: 'user', targetId: result.targetUserId, params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email, tripId: String(req.params.id) } }).catch(() => {});
|
||||
});
|
||||
|
||||
res.status(201).json({ member: result.member });
|
||||
} catch (e: any) {
|
||||
if (e instanceof NotFoundError) return res.status(404).json({ error: e.message });
|
||||
if (e instanceof ValidationError) return res.status(400).json({ error: e.message });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Remove member ─────────────────────────────────────────────────────────
|
||||
|
||||
router.delete('/:id/members/:userId', 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 targetId = parseInt(req.params.userId);
|
||||
const isSelf = targetId === authReq.user.id;
|
||||
if (!isSelf) {
|
||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
const memberCheck = access.user_id !== authReq.user.id;
|
||||
if (!checkPermission('member_manage', authReq.user.role, access.user_id, authReq.user.id, memberCheck))
|
||||
return res.status(403).json({ error: 'No permission to remove members' });
|
||||
}
|
||||
|
||||
removeMember(req.params.id, targetId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Offline bundle ────────────────────────────────────────────────────────
|
||||
// Returns all trip sub-collections in a single request for offline caching.
|
||||
|
||||
router.get('/:id/bundle', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const tripId = req.params.id;
|
||||
|
||||
const trip = getTrip(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const { days } = listDays(tripId);
|
||||
const places = listPlaces(String(tripId), {});
|
||||
const packingItems = listPackingItems(tripId);
|
||||
const todoItems = listTodoItems(tripId);
|
||||
const budgetItems = listBudgetItems(tripId);
|
||||
const reservations = listReservations(tripId);
|
||||
const files = listFiles(tripId, false);
|
||||
const accommodations = listAccommodations(tripId);
|
||||
const { owner, members } = listMembers(tripId, trip.user_id);
|
||||
const allMembers = [owner, ...(members || [])].filter(Boolean);
|
||||
|
||||
res.json({
|
||||
trip,
|
||||
days,
|
||||
places,
|
||||
packingItems,
|
||||
todoItems,
|
||||
budgetItems,
|
||||
reservations,
|
||||
files,
|
||||
accommodations,
|
||||
members: allMembers,
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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' });
|
||||
|
||||
try {
|
||||
const { ics, filename } = exportICS(req.params.id);
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(ics);
|
||||
} catch (e: any) {
|
||||
if (e instanceof NotFoundError) return res.status(404).json({ error: e.message });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,194 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as svc from '../services/vacayService';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/plan', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(svc.getPlanData(authReq.user.id));
|
||||
});
|
||||
|
||||
router.put('/plan', async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const result = await svc.updatePlan(planId, req.body, req.headers['x-socket-id'] as string);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post('/plan/holiday-calendars', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { region, label, color, sort_order } = req.body;
|
||||
if (!region) return res.status(400).json({ error: 'region required' });
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const calendar = svc.addHolidayCalendar(planId, region, label, color, sort_order, req.headers['x-socket-id'] as string);
|
||||
res.json({ calendar });
|
||||
});
|
||||
|
||||
router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const calendar = svc.updateHolidayCalendar(id, planId, req.body, req.headers['x-socket-id'] as string);
|
||||
if (!calendar) return res.status(404).json({ error: 'Calendar not found' });
|
||||
res.json({ calendar });
|
||||
});
|
||||
|
||||
router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const deleted = svc.deleteHolidayCalendar(id, planId, req.headers['x-socket-id'] as string);
|
||||
if (!deleted) return res.status(404).json({ error: 'Calendar not found' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/color', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { color, target_user_id } = req.body;
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id;
|
||||
const planUsers = svc.getPlanUsers(planId);
|
||||
if (!planUsers.find(u => u.id === userId)) {
|
||||
return res.status(403).json({ error: 'User not in plan' });
|
||||
}
|
||||
svc.setUserColor(userId, planId, color, req.headers['x-socket-id'] as string);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/invite', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { user_id } = req.body;
|
||||
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
||||
const plan = svc.getActivePlan(authReq.user.id);
|
||||
const result = svc.sendInvite(plan.id, authReq.user.id, authReq.user.username, authReq.user.email, user_id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/invite/accept', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { plan_id } = req.body;
|
||||
const result = svc.acceptInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/invite/decline', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { plan_id } = req.body;
|
||||
svc.declineInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/invite/cancel', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { user_id } = req.body;
|
||||
const plan = svc.getActivePlan(authReq.user.id);
|
||||
svc.cancelInvite(plan.id, user_id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/dissolve', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
svc.dissolvePlan(authReq.user.id, req.headers['x-socket-id'] as string);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/available-users', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const users = svc.getAvailableUsers(authReq.user.id, planId);
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
router.get('/years', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
res.json({ years: svc.listYears(planId) });
|
||||
});
|
||||
|
||||
router.post('/years', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { year } = req.body;
|
||||
if (!year) return res.status(400).json({ error: 'Year required' });
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const years = svc.addYear(planId, year, req.headers['x-socket-id'] as string);
|
||||
res.json({ years });
|
||||
});
|
||||
|
||||
router.delete('/years/:year', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const year = parseInt(req.params.year);
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const years = svc.deleteYear(planId, year, req.headers['x-socket-id'] as string);
|
||||
res.json({ years });
|
||||
});
|
||||
|
||||
router.get('/entries/:year', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
res.json(svc.getEntries(planId, req.params.year));
|
||||
});
|
||||
|
||||
router.post('/entries/toggle', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { date, target_user_id } = req.body;
|
||||
if (!date) return res.status(400).json({ error: 'date required' });
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
let userId = authReq.user.id;
|
||||
if (target_user_id && parseInt(target_user_id) !== authReq.user.id) {
|
||||
const planUsers = svc.getPlanUsers(planId);
|
||||
const tid = parseInt(target_user_id);
|
||||
if (!planUsers.find(u => u.id === tid)) {
|
||||
return res.status(403).json({ error: 'User not in plan' });
|
||||
}
|
||||
userId = tid;
|
||||
}
|
||||
res.json(svc.toggleEntry(userId, planId, date, req.headers['x-socket-id'] as string));
|
||||
});
|
||||
|
||||
router.post('/entries/company-holiday', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { date, note } = req.body;
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
res.json(svc.toggleCompanyHoliday(planId, date, note, req.headers['x-socket-id'] as string));
|
||||
});
|
||||
|
||||
router.get('/stats/:year', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const year = parseInt(req.params.year);
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
res.json({ stats: svc.getStats(planId, year) });
|
||||
});
|
||||
|
||||
router.put('/stats/:year', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const year = parseInt(req.params.year);
|
||||
const { vacation_days, target_user_id } = req.body;
|
||||
const planId = svc.getActivePlanId(authReq.user.id);
|
||||
const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id;
|
||||
const planUsers = svc.getPlanUsers(planId);
|
||||
if (!planUsers.find(u => u.id === userId)) {
|
||||
return res.status(403).json({ error: 'User not in plan' });
|
||||
}
|
||||
svc.updateStats(userId, planId, year, vacation_days, req.headers['x-socket-id'] as string);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/holidays/countries', async (_req: Request, res: Response) => {
|
||||
const result = await svc.getCountries();
|
||||
if (result.error) return res.status(502).json({ error: result.error });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
router.get('/holidays/:year/:country', async (req: Request, res: Response) => {
|
||||
const { year, country } = req.params;
|
||||
const result = await svc.getHolidays(year, country);
|
||||
if (result.error) return res.status(502).json({ error: result.error });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -113,10 +113,6 @@ export function getFileById(id: string | number, tripId: string | number): TripF
|
||||
return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
|
||||
}
|
||||
|
||||
export function getFileByIdFull(id: string | number): TripFile {
|
||||
return db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
|
||||
}
|
||||
|
||||
export function getDeletedFile(id: string | number, tripId: string | number): TripFile | undefined {
|
||||
return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from '../db/database';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider, deleteTrekPhotoIfOrphan } from './memories/photoResolverService';
|
||||
@@ -254,6 +254,11 @@ export function deleteJourney(journeyId: number, userId: number): boolean {
|
||||
// ── Trip management ──────────────────────────────────────────────────────
|
||||
|
||||
export function addTripToJourney(journeyId: number, tripId: number, userId: number): boolean {
|
||||
// Only attach a trip the caller can actually access — otherwise a journey
|
||||
// owner could pull an arbitrary trip's places + photos into their journey
|
||||
// (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped
|
||||
// path enforces.
|
||||
if (!canAccessTrip(tripId, userId)) return false;
|
||||
const now = ts();
|
||||
try {
|
||||
db.prepare(
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* GET /api/addons e2e — exercises the AddonsController through the real
|
||||
* JwtAuthGuard against a temp SQLite db. getCollabFeatures + getPhotoProviderConfig
|
||||
* are mocked; the addons/photo_providers/photo_provider_fields reads run against
|
||||
* the temp db. Asserts the byte-identical body the legacy inline handler produced.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, type TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT,
|
||||
label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER,
|
||||
settings_key TEXT, payload_key TEXT, sort_order INTEGER);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
|
||||
const { getCollabFeatures, getPhotoProviderConfig } = vi.hoisted(() => ({
|
||||
getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })),
|
||||
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
|
||||
}));
|
||||
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures }));
|
||||
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
||||
|
||||
import { AddonsModule } from '../../src/nest/addons/addons.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AddonsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run();
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('disabled','Disabled','trip','X',0,2)").run();
|
||||
db.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES ('immich','Immich','Image',1,1)").run();
|
||||
db.prepare(`INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES ('immich','base_url','Base URL','text','https://...',NULL,1,0,'immich_url',NULL,1)`).run();
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/addons')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 returns enabled addons + photo providers (disabled addon excluded)', async () => {
|
||||
const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
|
||||
addons: [
|
||||
{ id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true },
|
||||
{
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
icon: 'Image',
|
||||
enabled: true,
|
||||
config: { url: 'https://immich.example' },
|
||||
fields: [
|
||||
{
|
||||
key: 'base_url',
|
||||
label: 'Base URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://...',
|
||||
hint: null,
|
||||
required: true,
|
||||
secret: false,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Memories (photo-providers) module e2e — exercises the migrated
|
||||
* /api/integrations/memories endpoints (unified + immich + synologyphotos)
|
||||
* through the real JwtAuthGuard against a temp SQLite db. The provider services
|
||||
* and canAccessUserPhoto are mocked; fail/success stay real so the envelope
|
||||
* shapes are produced by the actual helper code.
|
||||
*
|
||||
* Focus: auth (401), every route's happy path, the CRITICAL 200-on-failure
|
||||
* behaviour of /test + /status, and at least one error envelope per provider
|
||||
* router — all asserted byte-identical to the legacy Express routers.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, canAccessTrip: vi.fn(), closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
// Provider services — fully mocked. fail/success/canAccessUserPhoto from the
|
||||
// helper module are kept real except canAccessUserPhoto which we override.
|
||||
const { unified, immich, synology } = vi.hoisted(() => ({
|
||||
unified: {
|
||||
listTripPhotos: vi.fn(), addTripPhotos: vi.fn(), setTripPhotoSharing: vi.fn(),
|
||||
removeTripPhoto: vi.fn(), listTripAlbumLinks: vi.fn(), createTripAlbumLink: vi.fn(), removeAlbumLink: vi.fn(),
|
||||
},
|
||||
immich: {
|
||||
getConnectionSettings: vi.fn(), saveImmichSettings: vi.fn(), setImmichAutoUpload: vi.fn(),
|
||||
testConnection: vi.fn(), getConnectionStatus: vi.fn(), browseTimeline: vi.fn(), searchPhotos: vi.fn(),
|
||||
streamImmichAsset: vi.fn(), listAlbums: vi.fn(), getAlbumPhotos: vi.fn(), syncAlbumAssets: vi.fn(),
|
||||
getAssetInfo: vi.fn(), isValidAssetId: vi.fn(),
|
||||
},
|
||||
synology: {
|
||||
getSynologySettings: vi.fn(), updateSynologySettings: vi.fn(), getSynologyStatus: vi.fn(),
|
||||
testSynologyConnection: vi.fn(), listSynologyAlbums: vi.fn(), getSynologyAlbumPhotos: vi.fn(),
|
||||
syncSynologyAlbumLink: vi.fn(), searchSynologyPhotos: vi.fn(), getSynologyAssetInfo: vi.fn(),
|
||||
streamSynologyAsset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/memories/unifiedService', () => unified);
|
||||
vi.mock('../../src/services/memories/immichService', () => immich);
|
||||
vi.mock('../../src/services/memories/synologyService', () => synology);
|
||||
|
||||
const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() }));
|
||||
vi.mock('../../src/services/memories/helpersService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/services/memories/helpersService')>(
|
||||
'../../src/services/memories/helpersService',
|
||||
);
|
||||
return { ...actual, canAccessUserPhoto };
|
||||
});
|
||||
|
||||
import { MemoriesModule } from '../../src/nest/memories/memories.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const BASE = '/api/integrations/memories';
|
||||
const UNIFIED = `${BASE}/unified`;
|
||||
const IMMICH = `${BASE}/immich`;
|
||||
const SYNO = `${BASE}/synologyphotos`;
|
||||
|
||||
describe('Memories e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [MemoriesModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
canAccessUserPhoto.mockReturnValue(true);
|
||||
immich.isValidAssetId.mockReturnValue(true);
|
||||
});
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────────
|
||||
describe('auth', () => {
|
||||
it('401 without a cookie (unified photos)', async () => {
|
||||
expect((await request(server).get(`${UNIFIED}/trips/5/photos`)).status).toBe(401);
|
||||
});
|
||||
it('401 without a cookie (immich status)', async () => {
|
||||
expect((await request(server).get(`${IMMICH}/status`)).status).toBe(401);
|
||||
});
|
||||
it('401 without a cookie (synology albums)', async () => {
|
||||
expect((await request(server).get(`${SYNO}/albums`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unified ──────────────────────────────────────────────────────────────────
|
||||
describe('unified', () => {
|
||||
it('200 list photos -> { photos }', async () => {
|
||||
unified.listTripPhotos.mockReturnValue({ success: true, data: [{ photo_id: 1, asset_id: 'a' }] });
|
||||
const res = await request(server).get(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ photos: [{ photo_id: 1, asset_id: 'a' }] });
|
||||
});
|
||||
|
||||
it('200 add photos -> { success, added } (POST stays 200, not 201)', async () => {
|
||||
unified.addTripPhotos.mockResolvedValue({ success: true, data: { added: 2, shared: true } });
|
||||
const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1))
|
||||
.send({ shared: true, selections: [{ provider: 'immich', asset_ids: ['a', 'b'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, added: 2 });
|
||||
// x-socket-id absent -> undefined, matching the legacy `req.headers['x-socket-id'] as string`.
|
||||
expect(unified.addTripPhotos).toHaveBeenCalledWith('5', 1, true, [{ provider: 'immich', asset_ids: ['a', 'b'] }], undefined);
|
||||
});
|
||||
|
||||
it('400 add photos with empty selections -> error envelope', async () => {
|
||||
unified.addTripPhotos.mockResolvedValue({ success: false, error: { message: 'No photos selected', status: 400 } });
|
||||
const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ selections: [] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'No photos selected' });
|
||||
});
|
||||
|
||||
it('200 PUT sharing -> { success: true }', async () => {
|
||||
unified.setTripPhotoSharing.mockResolvedValue({ success: true, data: true });
|
||||
const res = await request(server).put(`${UNIFIED}/trips/5/photos/sharing`).set('Cookie', sessionCookie(1)).send({ photo_id: 9, shared: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('404 DELETE photo on inaccessible trip -> error envelope', async () => {
|
||||
unified.removeTripPhoto.mockReturnValue({ success: false, error: { message: 'Trip not found or access denied', status: 404 } });
|
||||
const res = await request(server).delete(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ photo_id: 9 });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found or access denied' });
|
||||
});
|
||||
|
||||
it('200 list album-links -> { links }', async () => {
|
||||
unified.listTripAlbumLinks.mockReturnValue({ success: true, data: [{ id: 'l1' }] });
|
||||
const res = await request(server).get(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ links: [{ id: 'l1' }] });
|
||||
});
|
||||
|
||||
it('200 create album-link / 409 duplicate envelope', async () => {
|
||||
unified.createTripAlbumLink.mockReturnValue({ success: true, data: true });
|
||||
const ok = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ success: true });
|
||||
|
||||
unified.createTripAlbumLink.mockReturnValue({ success: false, error: { message: 'Album already linked', status: 409 } });
|
||||
const dup = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' });
|
||||
expect(dup.status).toBe(409);
|
||||
expect(dup.body).toEqual({ error: 'Album already linked' });
|
||||
});
|
||||
|
||||
it('200 DELETE album-link -> { success: true }', async () => {
|
||||
unified.removeAlbumLink.mockReturnValue({ success: true, data: true });
|
||||
const res = await request(server).delete(`${UNIFIED}/trips/5/album-links/7`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Immich ───────────────────────────────────────────────────────────────────
|
||||
describe('immich', () => {
|
||||
it('200 settings', async () => {
|
||||
immich.getConnectionSettings.mockReturnValue({ immich_url: '', connected: false, auto_upload: false });
|
||||
const res = await request(server).get(`${IMMICH}/settings`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ immich_url: '', connected: false, auto_upload: false });
|
||||
});
|
||||
|
||||
it('200 PUT settings success / 400 invalid url', async () => {
|
||||
immich.saveImmichSettings.mockResolvedValue({ success: true });
|
||||
const ok = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', auto_upload: true });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ success: true });
|
||||
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(1, true);
|
||||
|
||||
immich.saveImmichSettings.mockResolvedValue({ success: false, error: 'Invalid Immich URL: bad' });
|
||||
const bad = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'bad' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body).toEqual({ error: 'Invalid Immich URL: bad' });
|
||||
});
|
||||
|
||||
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
|
||||
immich.getConnectionStatus.mockResolvedValue({ connected: false, error: 'Not configured' });
|
||||
const res = await request(server).get(`${IMMICH}/status`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: false, error: 'Not configured' });
|
||||
});
|
||||
|
||||
it('CRITICAL: 200 /test missing fields -> { connected: false, error } without calling service', async () => {
|
||||
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: false, error: 'URL and API key required' });
|
||||
expect(immich.testConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 /test with creds delegates to service', async () => {
|
||||
immich.testConnection.mockResolvedValue({ connected: true, user: { name: 'T' } });
|
||||
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: true, user: { name: 'T' } });
|
||||
});
|
||||
|
||||
it('200 browse / 400 not configured', async () => {
|
||||
immich.browseTimeline.mockResolvedValue({ buckets: [{ count: 3 }] });
|
||||
const ok = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ buckets: [{ count: 3 }] });
|
||||
|
||||
immich.browseTimeline.mockResolvedValue({ error: 'Immich not configured', status: 400 });
|
||||
const bad = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1));
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body).toEqual({ error: 'Immich not configured' });
|
||||
});
|
||||
|
||||
it('200 search (POST stays 200) / 502 envelope', async () => {
|
||||
immich.searchPhotos.mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true });
|
||||
const ok = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({ page: 1, size: 50 });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ assets: [{ id: 'a' }], hasMore: true });
|
||||
expect(immich.searchPhotos).toHaveBeenCalledWith(1, undefined, undefined, 1, 50);
|
||||
|
||||
immich.searchPhotos.mockResolvedValue({ error: 'Could not reach Immich', status: 502 });
|
||||
const bad = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({});
|
||||
expect(bad.status).toBe(502);
|
||||
expect(bad.body).toEqual({ error: 'Could not reach Immich' });
|
||||
});
|
||||
|
||||
it('200 asset info / 400 invalid id / 403 no access', async () => {
|
||||
immich.getAssetInfo.mockResolvedValue({ data: { id: 'asset-1', city: 'Paris' } });
|
||||
const ok = await request(server).get(`${IMMICH}/assets/5/asset-1/1/info`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ id: 'asset-1', city: 'Paris' });
|
||||
|
||||
immich.isValidAssetId.mockReturnValue(false);
|
||||
const invalid = await request(server).get(`${IMMICH}/assets/5/bad/1/info`).set('Cookie', sessionCookie(1));
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(invalid.body).toEqual({ error: 'Invalid asset ID' });
|
||||
|
||||
immich.isValidAssetId.mockReturnValue(true);
|
||||
canAccessUserPhoto.mockReturnValue(false);
|
||||
const forbidden = await request(server).get(`${IMMICH}/assets/5/asset-1/2/info`).set('Cookie', sessionCookie(1));
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body).toEqual({ error: 'Forbidden' });
|
||||
});
|
||||
|
||||
it('streams thumbnail bytes via the service helper', async () => {
|
||||
immich.streamImmichAsset.mockImplementation(async (res: any) => {
|
||||
res.status(200);
|
||||
res.set('Content-Type', 'image/webp');
|
||||
res.end(Buffer.from('thumb-bytes'));
|
||||
});
|
||||
const res = await request(server).get(`${IMMICH}/assets/5/asset-1/1/thumbnail`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/webp');
|
||||
expect(immich.streamImmichAsset).toHaveBeenCalledWith(expect.anything(), 1, 'asset-1', 'thumbnail', 1);
|
||||
});
|
||||
|
||||
it('200 albums / 200 album photos', async () => {
|
||||
immich.listAlbums.mockResolvedValue({ albums: [{ id: 'al' }] });
|
||||
const albums = await request(server).get(`${IMMICH}/albums`).set('Cookie', sessionCookie(1));
|
||||
expect(albums.status).toBe(200);
|
||||
expect(albums.body).toEqual({ albums: [{ id: 'al' }] });
|
||||
|
||||
immich.getAlbumPhotos.mockResolvedValue({ assets: [{ id: 'p1' }] });
|
||||
const photos = await request(server).get(`${IMMICH}/albums/al/photos`).set('Cookie', sessionCookie(1));
|
||||
expect(photos.status).toBe(200);
|
||||
expect(photos.body).toEqual({ assets: [{ id: 'p1' }] });
|
||||
});
|
||||
|
||||
it('200 album sync (POST stays 200) / 404 envelope', async () => {
|
||||
immich.syncAlbumAssets.mockResolvedValue({ success: true, added: 3, total: 10 });
|
||||
const ok = await request(server).post(`${IMMICH}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ success: true, added: 3, total: 10 });
|
||||
|
||||
immich.syncAlbumAssets.mockResolvedValue({ error: 'Album link not found', status: 404 });
|
||||
const bad = await request(server).post(`${IMMICH}/trips/5/album-links/9/sync`).set('Cookie', sessionCookie(1));
|
||||
expect(bad.status).toBe(404);
|
||||
expect(bad.body).toEqual({ error: 'Album link not found' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Synology ───────────────────────────────────────────────────────────────
|
||||
describe('synologyphotos', () => {
|
||||
it('200 settings', async () => {
|
||||
synology.getSynologySettings.mockResolvedValue({ success: true, data: { synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true } });
|
||||
const res = await request(server).get(`${SYNO}/settings`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true });
|
||||
});
|
||||
|
||||
it('400 PUT settings without url/username -> envelope', async () => {
|
||||
const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: '' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'URL and username are required' });
|
||||
expect(synology.updateSynologySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 PUT settings delegates when valid', async () => {
|
||||
synology.updateSynologySettings.mockResolvedValue({ success: true, data: 'settings updated' });
|
||||
const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual('settings updated');
|
||||
});
|
||||
|
||||
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
|
||||
synology.getSynologyStatus.mockResolvedValue({ success: true, data: { connected: false, error: 'Synology not configured' } });
|
||||
const res = await request(server).get(`${SYNO}/status`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: false, error: 'Synology not configured' });
|
||||
});
|
||||
|
||||
it('CRITICAL: 200 /test missing fields -> 200 { connected: false, error } without calling service', async () => {
|
||||
const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: false, error: 'Username, Password are required' });
|
||||
expect(synology.testSynologyConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 /test delegates when all fields present', async () => {
|
||||
synology.testSynologyConnection.mockResolvedValue({ success: true, data: { connected: true, user: { name: 'admin' } } });
|
||||
const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: true, user: { name: 'admin' } });
|
||||
});
|
||||
|
||||
it('200 albums / 200 album photos with passphrase', async () => {
|
||||
synology.listSynologyAlbums.mockResolvedValue({ success: true, data: { albums: [{ id: '1', albumName: 'A', assetCount: 3 }] } });
|
||||
const albums = await request(server).get(`${SYNO}/albums`).set('Cookie', sessionCookie(1));
|
||||
expect(albums.status).toBe(200);
|
||||
expect(albums.body).toEqual({ albums: [{ id: '1', albumName: 'A', assetCount: 3 }] });
|
||||
|
||||
synology.getSynologyAlbumPhotos.mockResolvedValue({ success: true, data: { assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false } });
|
||||
const photos = await request(server).get(`${SYNO}/albums/1/photos?passphrase=secret`).set('Cookie', sessionCookie(1));
|
||||
expect(photos.status).toBe(200);
|
||||
expect(photos.body).toEqual({ assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false });
|
||||
expect(synology.getSynologyAlbumPhotos).toHaveBeenCalledWith(1, '1', 'secret');
|
||||
});
|
||||
|
||||
it('200 search (POST stays 200) with offset/limit coercion', async () => {
|
||||
synology.searchSynologyPhotos.mockResolvedValue({ success: true, data: { assets: [], total: 0, hasMore: false } });
|
||||
const res = await request(server).post(`${SYNO}/search`).set('Cookie', sessionCookie(1)).send({ page: 3, size: 20 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ assets: [], total: 0, hasMore: false });
|
||||
// page=3 -> (3-1)=2; size=20 -> limit=20; offset = 2 * 20 = 40
|
||||
expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(1, undefined, undefined, 40, 20);
|
||||
});
|
||||
|
||||
it('200 album sync (POST stays 200)', async () => {
|
||||
synology.syncSynologyAlbumLink.mockResolvedValue({ success: true, data: { added: 2, total: 5 } });
|
||||
const res = await request(server).post(`${SYNO}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ added: 2, total: 5 });
|
||||
});
|
||||
|
||||
it('200 asset info / 403 distinct synology string on no access', async () => {
|
||||
synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: '40808_1', takenAt: null } });
|
||||
const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/info`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ id: '40808_1', takenAt: null });
|
||||
|
||||
canAccessUserPhoto.mockReturnValue(false);
|
||||
const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/info`).set('Cookie', sessionCookie(1));
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body).toEqual({ error: "You don't have access to this photo" });
|
||||
});
|
||||
|
||||
it('400 invalid asset kind / 403 no access / stream on valid kind', async () => {
|
||||
const invalid = await request(server).get(`${SYNO}/assets/5/40808_1/1/bogus`).set('Cookie', sessionCookie(1));
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(invalid.body).toEqual({ error: 'Invalid asset kind' });
|
||||
|
||||
canAccessUserPhoto.mockReturnValue(false);
|
||||
const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/thumbnail`).set('Cookie', sessionCookie(1));
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body).toEqual({ error: "You don't have access to this photo" });
|
||||
|
||||
canAccessUserPhoto.mockReturnValue(true);
|
||||
synology.streamSynologyAsset.mockImplementation(async (res: any) => {
|
||||
res.status(200);
|
||||
res.set('Content-Type', 'image/jpeg');
|
||||
res.end(Buffer.from('syno-bytes'));
|
||||
});
|
||||
const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/thumbnail?size=xl`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.headers['content-type']).toContain('image/jpeg');
|
||||
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(expect.anything(), 1, 1, '40808_1', 'thumbnail', 'xl', undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
const { resv, budget, day } = vi.hoisted(() => ({
|
||||
resv: {
|
||||
verifyTripAccess: vi.fn(), listReservations: vi.fn(), createReservation: vi.fn(), updatePositions: vi.fn(),
|
||||
getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(),
|
||||
getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(), getUpcomingReservations: vi.fn(),
|
||||
},
|
||||
budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() },
|
||||
day: {
|
||||
@@ -72,6 +72,7 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite)', ()
|
||||
day.listAccommodations.mockReturnValue([{ id: 1 }]);
|
||||
day.validateAccommodationRefs.mockReturnValue([]);
|
||||
day.createAccommodation.mockReturnValue({ id: 9 });
|
||||
resv.getUpcomingReservations.mockReturnValue([{ id: 1, trip_id: 5, title: 'Flight' }]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -94,6 +95,16 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite)', ()
|
||||
expect(res.body).toEqual({ reservations: [{ id: 1, title: 'Hotel' }] });
|
||||
});
|
||||
|
||||
it('401 without a cookie (upcoming feed)', async () => {
|
||||
expect((await request(server).get('/api/reservations/upcoming')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 cross-trip upcoming reservations feed', async () => {
|
||||
const res = await request(server).get('/api/reservations/upcoming').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ reservations: [{ id: 1, trip_id: 5, title: 'Flight' }] });
|
||||
});
|
||||
|
||||
it('404 when trip not accessible (reservations)', async () => {
|
||||
resv.verifyTripAccess.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/reservations').set('Cookie', sessionCookie(1));
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { AuthPublicController } from '../../src/nest/auth/auth-public.controller';
|
||||
import type { RateLimitService } from '../../src/nest/auth/rate-limit.service';
|
||||
|
||||
// Tables to clear on reset, child-before-parent to be safe (FK checks are OFF during reset).
|
||||
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
|
||||
@@ -238,6 +241,22 @@ export function buildDbMock(testDb: Database.Database) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the Nest per-IP rate-limit buckets between tests — the buildApp() drop-in
|
||||
* for the legacy `loginAttempts.clear(); mfaAttempts.clear()`.
|
||||
*
|
||||
* The Nest auth path keeps its rate-limit state in a RateLimitService instance that
|
||||
* lives inside the AuthModule injector (shared by AuthPublicController/AuthController
|
||||
* for the login/mfa/forgot buckets). The same class is ALSO provided separately in
|
||||
* OauthModule (its own instance, distinct oauth_* buckets), so a plain
|
||||
* app.get(RateLimitService) is ambiguous and may hand back the wrong instance — we
|
||||
* resolve the auth controller and clear the limiter it actually uses.
|
||||
*/
|
||||
export function resetRateLimits(app: INestApplication): void {
|
||||
const ctrl = app.get(AuthPublicController, { strict: false }) as unknown as { rl: RateLimitService };
|
||||
ctrl.rl.reset();
|
||||
}
|
||||
|
||||
/** Fixed config mock — use with vi.mock('../../src/config', () => TEST_CONFIG) */
|
||||
export const TEST_CONFIG = {
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -46,31 +47,35 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, authHeader } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
// Reset rate limiter state between tests so they don't interfere
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -39,7 +40,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Mock filesystem-dependent service functions to avoid real disk I/O in tests
|
||||
vi.mock('../../src/services/backupService', async () => {
|
||||
@@ -69,32 +72,34 @@ vi.mock('../../src/services/backupService', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createAdmin, createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as backupService from '../../src/services/backupService';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* BOOTSTRAP / F6 — boots the unified production bootstrap (buildApp) and asserts
|
||||
* the whole shell is intact on the single NestJS instance now that Express is gone:
|
||||
* the global security pipeline (helmet/CSP), the /uploads platform routes, the
|
||||
* migrated /api domains (with the JWT guard), the /api/health + /api/addons
|
||||
* platform/inline endpoints, and (in production) HSTS. This is the test that proves
|
||||
* server/src/bootstrap.ts + index.ts serve everything correctly without the legacy app.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) => !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
|
||||
describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () => {
|
||||
let app: INestApplication;
|
||||
let instance: import('express').Application;
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
resetTestDb(testDb);
|
||||
app = await buildApp();
|
||||
instance = app.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
it('BOOT-001 — GET /api/health returns 200 { status: ok } (platform transport on Nest)', async () => {
|
||||
const res = await request(instance).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.headers['cache-control']).toContain('no-store');
|
||||
});
|
||||
|
||||
it('BOOT-002 — the global security pipeline (helmet) is applied', async () => {
|
||||
const res = await request(instance).get('/api/health');
|
||||
// helmet defaults — proof applyGlobalMiddleware ran on the Nest instance.
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['content-security-policy']).toBeDefined();
|
||||
});
|
||||
|
||||
it('BOOT-003 — public /api/config is reachable without auth (migrated Nest domain)', async () => {
|
||||
const res = await request(instance).get('/api/config');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('BOOT-004 — a protected /api domain rejects an anonymous request (JWT guard wired)', async () => {
|
||||
const res = await request(instance).get('/api/trips');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('BOOT-005 — /uploads/files is blocked without auth (platform uploads on Nest)', async () => {
|
||||
const res = await request(instance).get('/uploads/files/anything.bin');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('BOOT-006 — GET /api/addons works end-to-end (guard → Nest AddonsController)', async () => {
|
||||
const anon = await request(instance).get('/api/addons');
|
||||
expect(anon.status).toBe(401);
|
||||
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(instance).get('/api/addons').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.addons)).toBe(true);
|
||||
});
|
||||
|
||||
it('BOOT-007 — HSTS is advertised when NODE_ENV=production', async () => {
|
||||
const prev = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
let prodApp: INestApplication | undefined;
|
||||
try {
|
||||
prodApp = await buildApp();
|
||||
const res = await request(prodApp.getHttpAdapter().getInstance()).get('/api/health');
|
||||
expect(res.headers['strict-transport-security']).toContain('max-age=');
|
||||
} finally {
|
||||
if (prodApp) await prodApp.close();
|
||||
if (prev === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -30,30 +31,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
@@ -40,7 +41,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Partially mock collabService to make fetchLinkPreview controllable
|
||||
vi.mock('../../src/services/collabService', async (importOriginal) => {
|
||||
@@ -51,34 +54,36 @@ vi.mock('../../src/services/collabService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as collabService from '../../src/services/collabService';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
// Ensure uploads/files dir exists for collab file uploads
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -38,20 +39,30 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); });
|
||||
afterAll(() => { testDb.close(); });
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); resetRateLimits(nestApp); });
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List days (DAY-001, DAY-002)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
@@ -42,26 +43,30 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
|
||||
// Ensure uploads/files dir exists
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
// Seed allowed_file_types to include common types (wildcard)
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
@@ -69,13 +74,13 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
// Re-seed allowed_file_types after reset
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -38,7 +39,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Mock SSRF guard: block loopback and private IPs, allow external hostnames without DNS.
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
@@ -64,28 +67,30 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -43,6 +44,7 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({
|
||||
broadcast: vi.fn(),
|
||||
@@ -55,10 +57,10 @@ vi.mock('../../src/services/memories/immichService', () => ({
|
||||
getImmichCredentials: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import {
|
||||
createUser,
|
||||
createAdmin,
|
||||
@@ -68,23 +70,30 @@ import {
|
||||
addJourneyContributor,
|
||||
} from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
invalidatePermissionsCache();
|
||||
// Enable the journey addon
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)"
|
||||
).run();
|
||||
});
|
||||
afterAll(() => { testDb.close(); });
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List journeys (JOURNEY-INT-001, 002)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -38,7 +39,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for
|
||||
// URLs that look internal); individual tests override with mockResolvedValueOnce.
|
||||
@@ -53,29 +56,31 @@ vi.mock('../../src/services/mapsService', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as mapsService from '../../src/services/mapsService';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -38,33 +39,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { createMcpToken } from '../helpers/factories';
|
||||
import { closeMcpSessions } from '../../src/mcp/index';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
closeMcpSessions();
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,8 +37,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// ── SSRF guard mock — routes all Immich API calls to fake responses ───────────
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
@@ -164,31 +166,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
const IMMICH = '/api/integrations/memories/immich';
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Connection status ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +39,7 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
@@ -190,31 +192,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
const SYNO = '/api/integrations/memories/synologyphotos';
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,8 +37,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
|
||||
return {
|
||||
@@ -47,30 +49,34 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
const BASE = '/api/integrations/memories/unified';
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -95,33 +100,36 @@ describe('Photo endpoint auth', () => {
|
||||
|
||||
describe('Force HTTPS redirect', () => {
|
||||
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests on non-health paths', async () => {
|
||||
// createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance
|
||||
// applyGlobalMiddleware reads FORCE_HTTPS when buildApp() composes the app, so
|
||||
// we need a fresh Nest instance built with the flag set.
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
let httpsApp: INestApplication | undefined;
|
||||
try {
|
||||
httpsApp = createApp();
|
||||
httpsApp = await buildApp();
|
||||
const res = await request(httpsApp.getHttpAdapter().getInstance())
|
||||
.get('/api/addons')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
} finally {
|
||||
if (httpsApp) await httpsApp.close();
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/addons')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
});
|
||||
|
||||
it('MISC-008 — FORCE_HTTPS does not redirect /api/health (probes must reach it over HTTP)', async () => {
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
let httpsApp: INestApplication | undefined;
|
||||
try {
|
||||
httpsApp = createApp();
|
||||
httpsApp = await buildApp();
|
||||
const res = await request(httpsApp.getHttpAdapter().getInstance())
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
} finally {
|
||||
if (httpsApp) await httpsApp.close();
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -38,8 +39,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
|
||||
return {
|
||||
@@ -49,28 +51,30 @@ vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
@@ -37,6 +38,7 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
|
||||
const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
@@ -56,16 +58,16 @@ vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
// PKCE helpers
|
||||
function makePkce() {
|
||||
@@ -74,19 +76,33 @@ function makePkce() {
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// A7: under the unified Nest app the adminService mock only reaches the directly
|
||||
// imported isAddonEnabled (OauthService.mcpEnabled); oauthService.ts reads the
|
||||
// addon state through its own import that the Nest module graph loads unmocked,
|
||||
// so it falls back to the real DB row. Drive BOTH so the MCP-enabled state is
|
||||
// consistent across mcpEnabled() AND validateAuthorizeRequest()/token/revoke.
|
||||
function setMcpEnabled(enabled: boolean) {
|
||||
isAddonEnabledMock.mockReturnValue(enabled);
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)"
|
||||
).run(enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
isAddonEnabledMock.mockReturnValue(true);
|
||||
resetRateLimits(nestApp);
|
||||
setMcpEnabled(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -156,7 +172,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-003 — MCP addon disabled returns 404', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
|
||||
@@ -511,7 +527,7 @@ describe('POST /oauth/revoke', () => {
|
||||
|
||||
describe('GET /api/oauth/authorize/validate', () => {
|
||||
it('OAUTH-019 — returns 404 when MCP addon disabled (M2: prevents feature fingerprinting)', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' });
|
||||
@@ -697,7 +713,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-029 — 403 when MCP disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -772,7 +788,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
|
||||
describe('Client CRUD — /api/oauth/clients', () => {
|
||||
it('OAUTH-033 — GET returns 403 when addon disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -809,7 +825,7 @@ describe('Client CRUD — /api/oauth/clients', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-036 — POST returns 403 when addon disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -859,7 +875,7 @@ describe('Client CRUD — /api/oauth/clients', () => {
|
||||
|
||||
describe('Sessions — /api/oauth/sessions', () => {
|
||||
it('OAUTH-040 — GET returns 403 when addon disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -927,7 +943,7 @@ describe('Sessions — /api/oauth/sessions', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -952,13 +968,13 @@ describe('M1 — Cache-Control headers on /oauth/token', () => {
|
||||
|
||||
describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => {
|
||||
it('OAUTH-SEC-002 — /.well-known/oauth-authorization-server returns 404 when disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const res = await request(app).get('/.well-known/oauth-authorization-server');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('OAUTH-SEC-003 — /oauth/revoke returns 404 when disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.post('/oauth/revoke')
|
||||
.send({ token: 'x', client_id: 'y', client_secret: 'z' });
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ── DB mock (inline vi.hoisted pattern) ──────────────────────────────────────
|
||||
|
||||
@@ -34,7 +35,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// ── Mock only the HTTP-calling functions from oidcService ────────────────────
|
||||
vi.mock('../../src/services/oidcService', async (importOriginal) => {
|
||||
@@ -52,12 +55,11 @@ vi.mock('../../src/services/oidcService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as oidcService from '../../src/services/oidcService';
|
||||
|
||||
const mockDiscover = vi.mocked(oidcService.discover);
|
||||
@@ -71,17 +73,19 @@ const MOCK_DISCOVERY_DOC = {
|
||||
userinfo_endpoint: 'https://oidc.example.com/userinfo',
|
||||
};
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set OIDC environment variables for each test
|
||||
@@ -98,7 +102,8 @@ afterEach(() => {
|
||||
delete process.env.APP_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
@@ -41,7 +42,9 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/services/placeService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/placeService')>();
|
||||
return {
|
||||
@@ -51,36 +54,38 @@ vi.mock('../../src/services/placeService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as placeService from '../../src/services/placeService';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
||||
const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml');
|
||||
const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml');
|
||||
const KML_MALFORMED_FIXTURE = path.join(__dirname, '../fixtures/test-malformed.kml');
|
||||
const KMZ_FIXTURE = path.join(__dirname, '../fixtures/test.kmz');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
@@ -36,32 +37,36 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user