diff --git a/client/src/components/Dashboard/CurrencyWidget.tsx b/client/src/components/Dashboard/CurrencyWidget.tsx
deleted file mode 100644
index dbb841ff..00000000
--- a/client/src/components/Dashboard/CurrencyWidget.tsx
+++ /dev/null
@@ -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 (
-
-
- {t('dashboard.currency')}
-
-
-
- {/* Amount */}
-
- 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' }}
- />
-
-
- {/* From / Swap / To */}
-
-
- {/* Result */}
-
-
- {formatNumber(result)} {to}
-
- {rate &&
1 {from} = {rate.toFixed(4)} {to}
}
-
-
- )
-}
diff --git a/client/src/components/Dashboard/TimezoneWidget.test.tsx b/client/src/components/Dashboard/TimezoneWidget.test.tsx
deleted file mode 100644
index 8991728e..00000000
--- a/client/src/components/Dashboard/TimezoneWidget.test.tsx
+++ /dev/null
@@ -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()
- 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()
- const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
- expect(timeElements.length).toBeGreaterThan(0)
- })
-
- it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
- render()
- expect(screen.getByText(/timezones/i)).toBeInTheDocument()
- })
-
- it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
- localStorage.clear()
- render()
- 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()
- 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()
- 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()
- // 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()
- // 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()
- 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()
- // 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()
- 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()
- 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()
- 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()
- })
-})
diff --git a/client/src/components/Dashboard/TimezoneWidget.tsx b/client/src/components/Dashboard/TimezoneWidget.tsx
deleted file mode 100644
index f3b3a8e7..00000000
--- a/client/src/components/Dashboard/TimezoneWidget.tsx
+++ /dev/null
@@ -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 (
-
-
-
{t('dashboard.timezone')}
-
-
-
- {/* Local time */}
-
-
{localTime}
-
{localZone} ({tzAbbr}) · {t('dashboard.localTime')}
-
-
- {/* Zone list */}
-
- {zones.map(z => (
-
-
-
{getTime(z.tz, locale, is12h)}
-
{z.label} {getOffset(z.tz)}
-
-
-
- ))}
-
-
- {/* Add zone dropdown */}
- {showAdd && (
-
- {/* Custom timezone */}
-
-
{t('dashboard.timezoneCustomTitle')}
-
-
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)' }} />
-
{ 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 &&
{customError}
}
-
-
-
- {/* Popular zones */}
- {POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
-
- ))}
-
- )}
-
- )
-}
diff --git a/client/src/components/Layout/MobileTopHeader.test.tsx b/client/src/components/Layout/MobileTopHeader.test.tsx
deleted file mode 100644
index b8adca13..00000000
--- a/client/src/components/Layout/MobileTopHeader.test.tsx
+++ /dev/null
@@ -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();
- 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();
- expect(screen.getByText('3 trips')).toBeInTheDocument();
- });
-
- it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
- const { container } = render();
- const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
- expect(subtitleEl).not.toBeInTheDocument();
- });
-
- it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
- render(
- Add} />,
- );
- expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
- });
-});
diff --git a/client/src/components/Layout/MobileTopHeader.tsx b/client/src/components/Layout/MobileTopHeader.tsx
deleted file mode 100644
index 4f6f3052..00000000
--- a/client/src/components/Layout/MobileTopHeader.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-interface Props {
- title: string
- subtitle?: string
- actions?: React.ReactNode
-}
-
-export default function MobileTopHeader({ title, subtitle, actions }: Props) {
- return (
-
-
-
{title}
- {subtitle &&
{subtitle}
}
-
- {actions &&
{actions}
}
-
- )
-}
diff --git a/client/src/components/Memories/MemoriesPanel.test.tsx b/client/src/components/Memories/MemoriesPanel.test.tsx
deleted file mode 100644
index cbb914a2..00000000
--- a/client/src/components/Memories/MemoriesPanel.test.tsx
+++ /dev/null
@@ -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();
-
- // 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();
-
- // "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();
-
- // 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();
-
- // 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();
-
- 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();
-
- 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();
-
- 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();
-
- // 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();
-
- // 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();
-
- 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();
-
- 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();
-
- // 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();
-
- 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();
-
- // 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();
- 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();
- 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();
- 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();
-
- // 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();
- 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();
- 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();
-
- 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();
-
- // 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();
-
- // 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();
-
- 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();
-
- 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();
- 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();
- 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');
- });
-});
diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx
deleted file mode 100644
index 07da4828..00000000
--- a/client/src/components/Memories/MemoriesPanel.tsx
+++ /dev/null
@@ -1,1169 +0,0 @@
-import { useState, useEffect, useCallback } from 'react'
-import apiClient, { addonsApi } from '../../api/client'
-import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info, ChevronLeft, ChevronRight } from 'lucide-react'
-import { useAuthStore } from '../../store/authStore'
-import { useTranslation } from '../../i18n'
-import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
-import { useToast } from '../shared/Toast'
-
-interface PhotoProvider {
- id: string
- name: string
- icon?: string
- config?: Record
-}
-
-function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
- const [src, setSrc] = useState('')
- useEffect(() => {
- let revoke = ''
- fetchImageAsBlob('/api' + baseUrl).then(blobUrl => {
- revoke = blobUrl
- setSrc(blobUrl)
- })
- return () => { if (revoke) URL.revokeObjectURL(revoke) }
- }, [baseUrl])
- return src ?
: null
-}
-
-
-// ── Types ───────────────────────────────────────────────────────────────────
-
-interface TripPhoto {
- photo_id: number
- asset_id: string
- provider: string
- user_id: number
- username: string
- shared: number
- added_at: string
- city?: string | null
-}
-
-interface Asset {
- id: string
- provider: string
- takenAt: string
- city: string | null
- country: string | null
-}
-
-interface MemoriesPanelProps {
- tripId: number
- startDate: string | null
- endDate: string | null
-}
-
-// ── State + data ──────────────────────────────────────────────────────────
-
-/**
- * Memories state: provider connection/discovery, the saved trip photos, the
- * photo + album pickers, filters/sort and the lightbox (with EXIF info). All
- * URL building and API calls live here so the views below stay presentational.
- */
-function useMemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
- const { t } = useTranslation()
- const toast = useToast()
- const currentUser = useAuthStore(s => s.user)
-
- const [connected, setConnected] = useState(false)
- const [enabledProviders, setEnabledProviders] = useState([])
- const [availableProviders, setAvailableProviders] = useState([])
- const [selectedProvider, setSelectedProvider] = useState('')
- const [loading, setLoading] = useState(true)
-
- // Trip photos (saved selections)
- const [tripPhotos, setTripPhotos] = useState([])
-
- // Photo picker
- const [showPicker, setShowPicker] = useState(false)
- const [pickerPhotos, setPickerPhotos] = useState([])
- const [pickerLoading, setPickerLoading] = useState(false)
- const [selectedIds, setSelectedIds] = useState>(new Set())
-
- // Confirm share popup
- const [showConfirmShare, setShowConfirmShare] = useState(false)
-
- // Filters & sort
- const [sortAsc, setSortAsc] = useState(true)
- const [locationFilter, setLocationFilter] = useState('')
-
- // Album linking
- const [showAlbumPicker, setShowAlbumPicker] = useState(false)
- const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([])
- const [albumsLoading, setAlbumsLoading] = useState(false)
- const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
- const [syncing, setSyncing] = useState(null)
-
- //helpers for building urls
- const ADDON_PREFIX = "/integrations/memories"
-
- function buildUnifiedUrl(endpoint: string, lastParam?:string,): string {
- return `${ADDON_PREFIX}/unified/trips/${tripId}/${endpoint}${lastParam ? `/${lastParam}` : ''}`;
- }
-
- function buildProviderUrl(provider: string, endpoint: string, item?: string): string {
- if (endpoint === 'album-link-sync') {
- endpoint = `trips/${tripId}/album-links/${item?.toString() || ''}/sync`
- }
- return `${ADDON_PREFIX}/${provider}/${endpoint}`;
- }
-
- function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
- return `/photos/${photo.photo_id}/${what}`
- }
-
- function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
- // Picker photos are not yet saved — use provider-specific URL
- return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
- }
-
- const loadAlbumLinks = async () => {
- try {
- const res = await apiClient.get(buildUnifiedUrl('album-links'))
- setAlbumLinks(res.data.links || [])
- } catch { setAlbumLinks([]) }
- }
-
- const loadAlbums = async (provider: string = selectedProvider) => {
- if (!provider) return
- setAlbumsLoading(true)
- try {
- const res = await apiClient.get(buildProviderUrl(provider, 'albums'))
- setAlbums(res.data.albums || [])
- } catch {
- setAlbums([])
- toast.error(t('memories.error.loadAlbums'))
- } finally {
- setAlbumsLoading(false)
- }
- }
-
- const openAlbumPicker = async () => {
- setShowAlbumPicker(true)
- await loadAlbums(selectedProvider)
- }
-
- const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => {
- if (!selectedProvider) {
- toast.error(t('memories.error.linkAlbum'))
- return
- }
-
- try {
- await apiClient.post(buildUnifiedUrl('album-links'), {
- album_id: albumId,
- album_name: albumName,
- provider: selectedProvider,
- ...(passphrase ? { passphrase } : {}),
- })
- setShowAlbumPicker(false)
- await loadAlbumLinks()
- // Auto-sync after linking
- const linksRes = await apiClient.get(buildUnifiedUrl('album-links'))
- const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider)
- if (newLink) await syncAlbum(newLink.id)
- } catch { toast.error(t('memories.error.linkAlbum')) }
- }
-
- const unlinkAlbum = async (linkId: number) => {
- try {
- await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString()))
- await loadAlbumLinks()
- await loadPhotos()
- } catch { toast.error(t('memories.error.unlinkAlbum')) }
- }
-
- const syncAlbum = async (linkId: number, provider?: string) => {
- const targetProvider = provider || selectedProvider
- if (!targetProvider) return
- setSyncing(linkId)
- try {
- await apiClient.post(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString()))
- await loadAlbumLinks()
- await loadPhotos()
- } catch { toast.error(t('memories.error.syncAlbum')) }
- finally { setSyncing(null) }
- }
-
- // Lightbox
- const [lightboxId, setLightboxId] = useState(null)
- const [lightboxUserId, setLightboxUserId] = useState(null)
- const [lightboxInfo, setLightboxInfo] = useState(null)
- const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
- const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('')
- const [showMobileInfo, setShowMobileInfo] = useState(false)
- const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
-
- useEffect(() => {
- const handleResize = () => setIsMobile(window.innerWidth < 768)
- window.addEventListener('resize', handleResize)
- return () => window.removeEventListener('resize', handleResize)
- }, [])
-
- // ── Init ──────────────────────────────────────────────────────────────────
-
- useEffect(() => {
- loadInitial()
- }, [tripId])
-
- // WebSocket: reload photos when another user adds/removes/shares
- useEffect(() => {
- const handler = () => loadPhotos()
- window.addEventListener('memories:updated', handler)
- return () => window.removeEventListener('memories:updated', handler)
- }, [tripId])
-
- const loadPhotos = async () => {
- try {
- const photosRes = await apiClient.get(buildUnifiedUrl('photos'))
- setTripPhotos(photosRes.data.photos || [])
- } catch {
- setTripPhotos([])
- }
- }
-
- const loadInitial = async () => {
- setLoading(true)
- try {
- const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] }))
- const enabledAddons = addonsRes?.addons || []
- const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled)
-
- setEnabledProviders(photoProviders.map((a: any) => ({ id: a.id, name: a.name, icon: a.icon, config: a.config })))
-
- // Test connection status for each enabled provider
- const statusResults = await Promise.all(
- photoProviders.map(async (provider: any) => {
- const statusUrl = (provider.config as Record)?.status_get as string
- if (!statusUrl) return { provider, connected: false }
- try {
- const res = await apiClient.get(statusUrl)
- return { provider, connected: !!res.data?.connected }
- } catch {
- return { provider, connected: false }
- }
- })
- )
-
- const connectedProviders = statusResults
- .filter(r => r.connected)
- .map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config }))
-
- setAvailableProviders(connectedProviders)
- setConnected(connectedProviders.length > 0)
- if (connectedProviders.length > 0 && !selectedProvider) {
- setSelectedProvider(connectedProviders[0].id)
- }
- } catch {
- setAvailableProviders([])
- setConnected(false)
- }
- await loadPhotos()
- await loadAlbumLinks()
- setLoading(false)
- }
-
- // ── Photo Picker ──────────────────────────────────────────────────────────
-
- const [pickerDateFilter, setPickerDateFilter] = useState(true)
-
- const openPicker = async () => {
- setShowPicker(true)
- setPickerLoading(true)
- setSelectedIds(new Set())
- setPickerDateFilter(!!(startDate && endDate))
- await loadPickerPhotos(!!(startDate && endDate))
- }
-
- useEffect(() => {
- if (showPicker) {
- loadPickerPhotos(pickerDateFilter)
- }
- }, [selectedProvider])
-
- useEffect(() => {
- loadAlbumLinks()
- }, [tripId])
-
- useEffect(() => {
- if (showAlbumPicker) {
- loadAlbums(selectedProvider)
- }
- }, [showAlbumPicker, selectedProvider, tripId])
-
- const loadPickerPhotos = async (useDate: boolean) => {
- setPickerLoading(true)
- try {
- const provider = availableProviders.find(p => p.id === selectedProvider)
- if (!provider) {
- setPickerPhotos([])
- return
- }
- const res = await apiClient.post(buildProviderUrl(provider.id, 'search'), {
- from: useDate && startDate ? startDate : undefined,
- to: useDate && endDate ? endDate : undefined,
- })
- setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id })))
- } catch {
- setPickerPhotos([])
- toast.error(t('memories.error.loadPhotos'))
- } finally {
- setPickerLoading(false)
- }
- }
-
- const togglePickerSelect = (id: string) => {
- setSelectedIds(prev => {
- const next = new Set(prev)
- if (next.has(id)) next.delete(id)
- else next.add(id)
- return next
- })
- }
-
- const confirmSelection = () => {
- if (selectedIds.size === 0) return
- setShowConfirmShare(true)
- }
-
- const executeAddPhotos = async () => {
- setShowConfirmShare(false)
- try {
- const groupedByProvider = new Map()
- for (const key of selectedIds) {
- const [provider, assetId] = key.split('::')
- if (!provider || !assetId) continue
- const list = groupedByProvider.get(provider) || []
- list.push(assetId)
- groupedByProvider.set(provider, list)
- }
-
- await apiClient.post(buildUnifiedUrl('photos'), {
- selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })),
- shared: true,
- })
- setShowPicker(false)
- clearImageQueue()
- loadInitial()
- } catch { toast.error(t('memories.error.addPhotos')) }
- }
-
- // ── Remove photo ──────────────────────────────────────────────────────────
-
- const removePhoto = async (photo: TripPhoto) => {
- try {
- await apiClient.delete(buildUnifiedUrl('photos'), {
- data: {
- photo_id: photo.photo_id,
- },
- })
- setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id))
- } catch { toast.error(t('memories.error.removePhoto')) }
- }
-
- // ── Toggle sharing ────────────────────────────────────────────────────────
-
- const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
- try {
- await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
- shared,
- photo_id: photo.photo_id,
- })
- setTripPhotos(prev => prev.map(p =>
- p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p
- ))
- } catch { toast.error(t('memories.error.toggleSharing')) }
- }
-
- // ── Helpers ───────────────────────────────────────────────────────────────
-
- const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
-
- const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
- const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
- const allVisibleRaw = [...ownPhotos, ...othersPhotos]
-
- // Unique locations for filter
- const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort()
-
- // Apply filter + sort
- const allVisible = allVisibleRaw
- .filter(p => !locationFilter || p.city === locationFilter)
- .sort((a, b) => {
- const da = new Date(a.added_at || 0).getTime()
- const db = new Date(b.added_at || 0).getTime()
- return sortAsc ? da - db : db - da
- })
-
- const font: React.CSSProperties = {
- fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
- }
-
- return {
- t, currentUser,
- connected, enabledProviders, availableProviders, selectedProvider, setSelectedProvider, loading,
- tripPhotos, showPicker, setShowPicker, pickerPhotos, pickerLoading, selectedIds,
- showConfirmShare, setShowConfirmShare, sortAsc, setSortAsc, locationFilter, setLocationFilter,
- showAlbumPicker, setShowAlbumPicker, albums, albumsLoading, albumLinks, syncing,
- lightboxId, setLightboxId, lightboxUserId, setLightboxUserId, lightboxInfo, setLightboxInfo,
- lightboxInfoLoading, setLightboxInfoLoading, lightboxOriginalSrc, setLightboxOriginalSrc,
- showMobileInfo, setShowMobileInfo, isMobile, pickerDateFilter, setPickerDateFilter,
- startDate, endDate,
- buildProviderAssetUrl, buildProviderAssetUrlFromAsset,
- openAlbumPicker, linkAlbum, unlinkAlbum, syncAlbum, openPicker, loadPickerPhotos,
- togglePickerSelect, confirmSelection, executeAddPhotos, removePhoto, toggleSharing, makePickerKey,
- ownPhotos, othersPhotos, allVisibleRaw, locations, allVisible, font,
- }
-}
-
-type MemoriesState = ReturnType
-
-// ── Shared bits ───────────────────────────────────────────────────────────
-
-function ProviderTabs({ availableProviders, selectedProvider, setSelectedProvider }: MemoriesState) {
- if (availableProviders.length < 2) return null
- return (
-
- {availableProviders.map(provider => (
-
- ))}
-
- )
-}
-
-function ConfirmSharePopup(S: MemoriesState) {
- const { setShowConfirmShare, t, selectedIds, executeAddPhotos } = S
- return (
- setShowConfirmShare(false)}
- style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
-
e.stopPropagation()}
- style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
-
-
- {t('memories.confirmShareTitle')}
-
-
- {t('memories.confirmShareHint', { count: selectedIds.size })}
-
-
-
-
-
-
-
- )
-}
-
-// ── Early-state views ───────────────────────────────────────────────────────
-
-function MemoriesLoading({ font }: MemoriesState) {
- return (
-
- )
-}
-
-function MemoriesNotConnected({ font, t, enabledProviders }: MemoriesState) {
- return (
-
-
-
- {t('memories.notConnected', { provider_name: enabledProviders.length === 1 ? enabledProviders[0]?.name : 'Photo provider' })}
-
-
- {enabledProviders.length === 1 ? t('memories.notConnectedHint', { provider_name: enabledProviders[0]?.name }) : t('memories.notConnectedMultipleHint', { provider_names: enabledProviders.map(p => p.name).join(', ') })}
-
-
- )
-}
-
-function AlbumPickerView(S: MemoriesState) {
- const { font, t, availableProviders, selectedProvider, setShowAlbumPicker, albumsLoading, albums, albumLinks, linkAlbum } = S
- const linkedIds = new Set(albumLinks.map(l => l.album_id))
- return (
-
-
-
-
- {availableProviders.length > 1 ? t('memories.selectAlbumMultiple') : t('memories.selectAlbum', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
-
-
-
-
-
-
- {albumsLoading ? (
-
- ) : albums.length === 0 ? (
-
- {t('memories.noAlbums')}
-
- ) : (
-
- {albums.map(album => {
- const isLinked = linkedIds.has(album.id)
- return (
-
- )
- })}
-
- )}
-
-
- )
-}
-
-function PhotoPickerView(S: MemoriesState) {
- const {
- font, t, availableProviders, selectedProvider, tripPhotos, currentUser, makePickerKey,
- setShowPicker, confirmSelection, selectedIds, startDate, endDate, pickerDateFilter, setPickerDateFilter,
- loadPickerPhotos, pickerLoading, pickerPhotos, buildProviderAssetUrlFromAsset, togglePickerSelect, showConfirmShare,
- } = S
- const alreadyAdded = new Set(
- tripPhotos
- .filter(p => p.user_id === currentUser?.id)
- .map(p => makePickerKey(p.provider, p.asset_id))
- )
-
- return (
- <>
-
- {/* Picker header */}
-
-
-
- {availableProviders.length > 1 ? t('memories.selectPhotosMultiple') : t('memories.selectPhotos', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
-
-
-
-
-
-
-
- {/* Filter tabs */}
-
- {startDate && endDate && (
-
- )}
-
-
- {selectedIds.size > 0 && (
-
- {selectedIds.size} {t('memories.selected')}
-
- )}
-
-
- {/* Picker grid */}
-
- {pickerLoading ? (
-
- ) : pickerPhotos.length === 0 ? (
-
-
-
{t('memories.noPhotos')}
- {
- pickerDateFilter && (
-
- {t('memories.noPhotosHint', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
-
- )
- }
-
- ) : (() => {
- // Group photos by month
- const byMonth: Record
= {}
- for (const asset of pickerPhotos) {
- const d = asset.takenAt ? new Date(asset.takenAt) : null
- const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
- if (!byMonth[key]) byMonth[key] = []
- byMonth[key].push(asset)
- }
- const sortedMonths = Object.keys(byMonth).sort().reverse()
-
- return sortedMonths.map(month => (
-
-
- {month !== 'unknown'
- ? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
- : '—'}
-
-
- {byMonth[month].map(asset => {
- const pickerKey = makePickerKey(asset.provider, asset.id)
- const isSelected = selectedIds.has(pickerKey)
- const isAlready = alreadyAdded.has(pickerKey)
- return (
-
!isAlready && togglePickerSelect(pickerKey)}
- style={{
- position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
- cursor: isAlready ? 'default' : 'pointer',
- opacity: isAlready ? 0.3 : 1,
- outline: isSelected ? '3px solid var(--text-primary)' : 'none',
- outlineOffset: -3,
- }}>
-
- {isSelected && (
-
-
-
- )}
- {isAlready && (
-
- {t('memories.alreadyAdded')}
-
- )}
-
- )
- })}
-
-
- ))
- })()}
-
-
-
- {/* Confirm share popup (inside picker) */}
- {showConfirmShare && }
- >
- )
-}
-
-// ── Lightbox ────────────────────────────────────────────────────────────────
-
-function MemoriesLightbox(S: MemoriesState) {
- const {
- lightboxId, setLightboxId, setLightboxUserId, lightboxInfo, setLightboxInfo, lightboxInfoLoading,
- setLightboxInfoLoading, lightboxOriginalSrc, setLightboxOriginalSrc, showMobileInfo, setShowMobileInfo,
- isMobile, allVisible, buildProviderAssetUrl,
- } = S
- const closeLightbox = () => {
- if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
- setLightboxOriginalSrc('')
- setLightboxId(null)
- setLightboxUserId(null)
- setShowMobileInfo(false)
- }
-
- const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId)
- const hasPrev = currentIdx > 0
- const hasNext = currentIdx < allVisible.length - 1
- const navigateTo = (idx: number) => {
- const photo = allVisible[idx]
- if (!photo) return
- if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
- setLightboxOriginalSrc('')
- setLightboxId(photo.photo_id)
- setLightboxUserId(photo.user_id)
- setLightboxInfo(null)
- fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
- setLightboxInfoLoading(true)
- apiClient.get(buildProviderAssetUrl(photo, 'info'))
- .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
- }
-
- const exifContent = lightboxInfo ? (
- <>
- {lightboxInfo.takenAt && (
-
-
Date
-
{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}
-
{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
-
- )}
- {(lightboxInfo.city || lightboxInfo.country) && (
-
-
- Location
-
-
- {[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
-
-
- )}
- {lightboxInfo.camera && (
-
-
Camera
-
{lightboxInfo.camera}
- {lightboxInfo.lens &&
{lightboxInfo.lens}
}
-
- )}
- {(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
-
- {lightboxInfo.focalLength && (
-
-
Focal
-
{lightboxInfo.focalLength}
-
- )}
- {lightboxInfo.aperture && (
-
-
Aperture
-
{lightboxInfo.aperture}
-
- )}
- {lightboxInfo.shutter && (
-
-
Shutter
-
{lightboxInfo.shutter}
-
- )}
- {lightboxInfo.iso && (
-
-
ISO
-
{lightboxInfo.iso}
-
- )}
-
- )}
- {(lightboxInfo.width || lightboxInfo.fileName) && (
-
- {lightboxInfo.width && lightboxInfo.height && (
-
{lightboxInfo.width} × {lightboxInfo.height}
- )}
- {lightboxInfo.fileSize && (
-
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
- )}
-
- )}
- >
- ) : null
-
- return (
- { if (e.key === 'ArrowLeft' && hasPrev) navigateTo(currentIdx - 1); if (e.key === 'ArrowRight' && hasNext) navigateTo(currentIdx + 1); if (e.key === 'Escape') closeLightbox() }}
- tabIndex={0} ref={el => el?.focus()}
- onTouchStart={e => (e.currentTarget as any)._touchX = e.touches[0].clientX}
- onTouchEnd={e => { const start = (e.currentTarget as any)._touchX; if (start == null) return; const diff = e.changedTouches[0].clientX - start; if (diff > 60 && hasPrev) navigateTo(currentIdx - 1); else if (diff < -60 && hasNext) navigateTo(currentIdx + 1) }}
- style={{
- position: 'absolute', inset: 0, zIndex: 100, outline: 'none',
- background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
- }}>
- {/* Close button */}
-
-
- {/* Counter */}
- {allVisible.length > 1 && (
-
- {currentIdx + 1} / {allVisible.length}
-
- )}
-
- {/* Prev/Next buttons */}
- {hasPrev && (
-
- )}
- {hasNext && (
-
- )}
-
- {/* Mobile info toggle button */}
- {isMobile && (lightboxInfo || lightboxInfoLoading) && (
-
- )}
-
-
{ if (e.target === e.currentTarget) closeLightbox() }} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
-

e.stopPropagation()}
- style={{ maxWidth: (!isMobile && lightboxInfo) ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
- />
-
- {/* Desktop info panel — liquid glass */}
- {!isMobile && lightboxInfo && (
-
- {exifContent}
-
- )}
-
- {!isMobile && lightboxInfoLoading && (
-
- )}
-
-
- {/* Mobile bottom sheet */}
- {isMobile && showMobileInfo && lightboxInfo && (
-
e.stopPropagation()} style={{
- position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 5,
- maxHeight: '60vh', overflowY: 'auto',
- borderRadius: '16px 16px 0 0', padding: 18,
- background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
- border: '1px solid rgba(255,255,255,0.12)', borderBottom: 'none',
- color: 'white', display: 'flex', flexDirection: 'column', gap: 14,
- }}>
- {exifContent}
-
- )}
-
- )
-}
-
-// ── Main gallery ──────────────────────────────────────────────────────────
-
-function MainGallery(S: MemoriesState) {
- const {
- font, connected, allVisible, enabledProviders, t, allVisibleRaw, othersPhotos, openAlbumPicker, openPicker,
- albumLinks, currentUser, syncAlbum, syncing, unlinkAlbum, sortAsc, setSortAsc, locations, locationFilter, setLocationFilter,
- setLightboxId, setLightboxUserId, setLightboxInfo, lightboxOriginalSrc, setLightboxOriginalSrc, setLightboxInfoLoading,
- buildProviderAssetUrl, toggleSharing, removePhoto, showConfirmShare, lightboxId, lightboxUserId,
- } = S
- return (
-
-
- {/* Disconnected banner — shown when photos exist but provider is unreachable */}
- {!connected && allVisible.length > 0 && enabledProviders.length > 0 && (
-
-
-
- {t('memories.providerDisconnectedBanner', {
- provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ')
- })}
-
-
- )}
-
- {/* Header */}
-
-
-
-
- {t('memories.title')}
-
-
- {allVisible.length} {t('memories.photosFound')}
- {othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}
-
-
- {connected && (
-
-
-
-
- )}
-
-
- {/* Linked Albums */}
- {albumLinks.length > 0 && (
-
- {albumLinks.map(link => (
-
-
- {link.album_name}
- {link.username !== currentUser?.username && ({link.username})}
-
- {link.user_id === currentUser?.id && (
-
- )}
-
- ))}
-
- )}
-
-
- {/* Filter & Sort bar */}
- {allVisibleRaw.length > 0 && (
-
-
- {locations.length > 1 && (
-
- )}
-
- )}
-
- {/* Gallery */}
-
- {allVisible.length === 0 ? (
-
-
-
- {t('memories.noPhotos')}
-
-
-
- ) : (
-
- {allVisible.map(photo => {
- const isOwn = photo.user_id === currentUser?.id
- return (
-
{
- setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
- if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
- setLightboxOriginalSrc('')
- fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
- setLightboxInfoLoading(true)
- apiClient.get(buildProviderAssetUrl(photo, 'info'))
- .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
- }}>
-
-
-
- {/* Other user's avatar */}
- {!isOwn && (
-
-
- {photo.username[0]}
-
-
- {photo.username}
-
-
- )}
-
- {/* Own photo actions (hover) */}
- {isOwn && (
-
-
-
-
- )}
-
- {/* Not shared indicator */}
- {isOwn && !photo.shared && (
-
-
- {t('memories.private')}
-
- )}
-
- )
- })}
-
- )}
-
-
-
-
- {/* Confirm share popup */}
- {showConfirmShare &&
}
-
- {/* Lightbox */}
- {lightboxId && lightboxUserId &&
}
-
- )
-}
-
-// ── Main Component ──────────────────────────────────────────────────────────
-
-export default function MemoriesPanel(props: MemoriesPanelProps) {
- const S = useMemoriesPanel(props)
- if (S.loading) return
- if (!S.connected && S.allVisible.length === 0) return
- if (S.showAlbumPicker) return
- if (S.showPicker) return
- return
-}
diff --git a/client/src/components/shared/CopyButton.tsx b/client/src/components/shared/CopyButton.tsx
deleted file mode 100644
index 59f84dd6..00000000
--- a/client/src/components/shared/CopyButton.tsx
+++ /dev/null
@@ -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 (
-
- )
-}
-
-export default CopyButton
diff --git a/client/src/components/shared/LoadingImage.tsx b/client/src/components/shared/LoadingImage.tsx
deleted file mode 100644
index e71bdb64..00000000
--- a/client/src/components/shared/LoadingImage.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React, { useState, type ImgHTMLAttributes } from 'react'
-
-interface LoadingImageProps extends ImgHTMLAttributes {
- 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 (
-
- {!loaded && (
-
- )}
-
![]()
{ setLoaded(true); onLoad?.(e) }}
- />
-
- )
-}
-
-export default LoadingImage
diff --git a/client/src/hooks/usePendingMutations.ts b/client/src/hooks/usePendingMutations.ts
deleted file mode 100644
index 4d73b9aa..00000000
--- a/client/src/hooks/usePendingMutations.ts
+++ /dev/null
@@ -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 {
- const [pendingIds, setPendingIds] = useState>(new Set())
-
- useEffect(() => {
- let cancelled = false
-
- async function refresh() {
- const pending = await mutationQueue.pending(tripId)
- if (cancelled) return
-
- const ids = new Set()
- 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
-}
diff --git a/client/tests/unit/utils/reorder.test.ts b/client/tests/unit/utils/reorder.test.ts
deleted file mode 100644
index 50c7f27b..00000000
--- a/client/tests/unit/utils/reorder.test.ts
+++ /dev/null
@@ -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]);
- });
-});
diff --git a/server/package.json b/server/package.json
index 4ea047d7..0c730e65 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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"
},
diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts
new file mode 100644
index 00000000..9b4145b6
--- /dev/null
+++ b/server/src/bootstrap.ts
@@ -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 {
+ 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;
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index 7cc1c66c..2504898e 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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 {
- // 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;
diff --git a/server/src/mcp/tools/journey.ts b/server/src/mcp/tools/journey.ts
index 90f2e40a..10b985e7 100644
--- a/server/src/mcp/tools/journey.ts
+++ b/server/src/mcp/tools/journey.ts
@@ -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 });
}
diff --git a/server/src/nest/addons/addons.controller.ts b/server/src/nest/addons/addons.controller.ts
new file mode 100644
index 00000000..b9cf54ff
--- /dev/null
+++ b/server/src/nest/addons/addons.controller.ts
@@ -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();
+ }
+}
diff --git a/server/src/nest/addons/addons.module.ts b/server/src/nest/addons/addons.module.ts
new file mode 100644
index 00000000..078c3b52
--- /dev/null
+++ b/server/src/nest/addons/addons.module.ts
@@ -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 {}
diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts
new file mode 100644
index 00000000..aef063cd
--- /dev/null
+++ b/server/src/nest/addons/addons.service.ts
@@ -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[];
+ 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();
+ 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,
+ })),
+ })),
+ ],
+ };
+ }
+}
diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts
index 0c8de243..45922464 100644
--- a/server/src/nest/app.module.ts
+++ b/server/src/nest/app.module.ts
@@ -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 },
diff --git a/server/src/nest/collab/collab.controller.ts b/server/src/nest/collab/collab.controller.ts
index e1a80ff2..b51018b1 100644
--- a/server/src/nest/collab/collab.controller.ts
+++ b/server/src/nest/collab/collab.controller.ts
@@ -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')) {
diff --git a/server/src/nest/common/trek-exception.filter.ts b/server/src/nest/common/trek-exception.filter.ts
index 193ddf79..6b891078 100644
--- a/server/src/nest/common/trek-exception.filter.ts
+++ b/server/src/nest/common/trek-exception.filter.ts
@@ -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: } (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: }
+ * - { error, code? } bodies -> passed through unchanged (auth guards, ZodValidationPipe)
+ * - other HttpExceptions -> { error: } at the same status
+ * - plain errors w/ statusCode/status -> that status, { error: } 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: '' }` (400).
*/
@Catch()
export class TrekExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse();
+ // 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)) {
- res.status(status).json(body);
+ if (body && typeof body === 'object') {
+ const obj = body as Record;
+ // 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 });
}
}
diff --git a/server/src/nest/files/files-download.controller.ts b/server/src/nest/files/files-download.controller.ts
index aeed981b..2fab2d18 100644
--- a/server/src/nest/files/files-download.controller.ts
+++ b/server/src/nest/files/files-download.controller.ts
@@ -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) });
}
}
diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts
index 9ab22a48..cb290c66 100644
--- a/server/src/nest/files/files.controller.ts
+++ b/server/src/nest/files/files.controller.ts
@@ -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 = () => {
diff --git a/server/src/nest/memories/immich.controller.ts b/server/src/nest/memories/immich.controller.ts
new file mode 100644
index 00000000..d046ce87
--- /dev/null
+++ b/server/src/nest/memories/immich.controller.ts
@@ -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 {
+ 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 {
+ 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, @Res() res: Response): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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);
+ }
+ }
+}
diff --git a/server/src/nest/memories/memories.module.ts b/server/src/nest/memories/memories.module.ts
new file mode 100644
index 00000000..82a4640b
--- /dev/null
+++ b/server/src/nest/memories/memories.module.ts
@@ -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 {}
diff --git a/server/src/nest/memories/memories.service.ts b/server/src/nest/memories/memories.service.ts
new file mode 100644
index 00000000..e96faf64
--- /dev/null
+++ b/server/src/nest/memories/memories.service.ts
@@ -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, 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);
+ }
+}
diff --git a/server/src/nest/memories/synology.controller.ts b/server/src/nest/memories/synology.controller.ts
new file mode 100644
index 00000000..88db6133
--- /dev/null
+++ b/server/src/nest/memories/synology.controller.ts
@@ -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(res: Response, result: ServiceResult): 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 {
+ this.handle(res, await this.memories.synologyGetSettings(user.id));
+ }
+
+ @Put('settings')
+ async putSettings(@CurrentUser() user: User, @Body() body: Record, @Res() res: Response): Promise {
+ 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 {
+ this.handle(res, await this.memories.synologyGetStatus(user.id));
+ }
+
+ @Post('test')
+ @HttpCode(200)
+ async test(@CurrentUser() user: User, @Body() body: Record, @Res() res: Response): Promise {
+ 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 {
+ 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 {
+ 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 {
+ this.handle(res, await this.memories.synologySyncAlbumLink(user.id, tripId, linkId, sid));
+ }
+
+ @Post('search')
+ @HttpCode(200)
+ async search(@CurrentUser() user: User, @Body() body: Record, @Res() res: Response): Promise {
+ 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 {
+ 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 {
+ 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);
+ }
+ }
+}
diff --git a/server/src/nest/memories/unified.controller.ts b/server/src/nest/memories/unified.controller.ts
new file mode 100644
index 00000000..0c277086
--- /dev/null
+++ b/server/src/nest/memories/unified.controller.ts
@@ -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,
+ @Headers('x-socket-id') sid: string,
+ @Res() res: Response,
+ ): Promise {
+ 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,
+ @Res() res: Response,
+ ): Promise {
+ 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,
+ @Res() res: Response,
+ ): Promise {
+ 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,
+ @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 });
+ }
+}
diff --git a/server/src/app.ts b/server/src/nest/platform/platform.routes.ts
similarity index 51%
rename from server/src/app.ts
rename to server/src/nest/platform/platform.routes.ts
index e6cf6fe4..8b207f37 100644
--- a/server/src/app.ts
+++ b/server/src/nest/platform/platform.routes.ts
@@ -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[];
- 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();
- 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;
-}
\ No newline at end of file
+ }),
+ );
+}
diff --git a/server/src/nest/platform/spa-fallback.filter.ts b/server/src/nest/platform/spa-fallback.filter.ts
new file mode 100644
index 00000000..d1a12be2
--- /dev/null
+++ b/server/src/nest/platform/spa-fallback.filter.ts
@@ -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();
+ const res = ctx.getResponse();
+
+ 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' });
+ }
+}
diff --git a/server/src/nest/reservations/reservations.module.ts b/server/src/nest/reservations/reservations.module.ts
index 2ab9efcf..9401abde 100644
--- a/server/src/nest/reservations/reservations.module.ts
+++ b/server/src/nest/reservations/reservations.module.ts
@@ -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 {}
diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts
index f8747f92..5d1d7cdc 100644
--- a/server/src/nest/reservations/reservations.service.ts
+++ b/server/src/nest/reservations/reservations.service.ts
@@ -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[1]) {
return svc.createReservation(tripId, data);
}
diff --git a/server/src/nest/reservations/upcoming-reservations.controller.ts b/server/src/nest/reservations/upcoming-reservations.controller.ts
new file mode 100644
index 00000000..fc794449
--- /dev/null
+++ b/server/src/nest/reservations/upcoming-reservations.controller.ts
@@ -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) };
+ }
+}
diff --git a/server/src/nest/strangler.ts b/server/src/nest/strangler.ts
deleted file mode 100644
index 2265e85f..00000000
--- a/server/src/nest/strangler.ts
+++ /dev/null
@@ -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));
-}
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
deleted file mode 100644
index 16a5b40b..00000000
--- a/server/src/routes/admin.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/airports.ts b/server/src/routes/airports.ts
deleted file mode 100644
index 781ca982..00000000
--- a/server/src/routes/airports.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/assignments.ts b/server/src/routes/assignments.ts
deleted file mode 100644
index ae7abef8..00000000
--- a/server/src/routes/assignments.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts
deleted file mode 100644
index d5af8d86..00000000
--- a/server/src/routes/atlas.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
deleted file mode 100644
index d124ad44..00000000
--- a/server/src/routes/auth.ts
+++ /dev/null
@@ -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();
-const mfaAttempts = new Map();
-const forgotAttempts = new Map();
-const resetAttempts = new Map();
-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 };
diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts
deleted file mode 100644
index 58e5995a..00000000
--- a/server/src/routes/backup.ts
+++ /dev/null
@@ -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);
- 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;
diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts
deleted file mode 100644
index b0ec405b..00000000
--- a/server/src/routes/budget.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/categories.ts b/server/src/routes/categories.ts
deleted file mode 100644
index b33712e7..00000000
--- a/server/src/routes/categories.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts
deleted file mode 100644
index fc2b97ff..00000000
--- a/server/src/routes/collab.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/dayNotes.ts b/server/src/routes/dayNotes.ts
deleted file mode 100644
index 48c46ff2..00000000
--- a/server/src/routes/dayNotes.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/days.ts b/server/src/routes/days.ts
deleted file mode 100644
index 659b7096..00000000
--- a/server/src/routes/days.ts
+++ /dev/null
@@ -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 };
diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts
deleted file mode 100644
index 65e3d250..00000000
--- a/server/src/routes/files.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts
deleted file mode 100644
index b655d7ce..00000000
--- a/server/src/routes/journey.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/journeyPublic.ts b/server/src/routes/journeyPublic.ts
deleted file mode 100644
index 37dd167e..00000000
--- a/server/src/routes/journeyPublic.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts
deleted file mode 100644
index 427d4500..00000000
--- a/server/src/routes/maps.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts
deleted file mode 100644
index c12b4c6e..00000000
--- a/server/src/routes/memories/immich.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts
deleted file mode 100644
index bb325f4d..00000000
--- a/server/src/routes/memories/synology.ts
+++ /dev/null
@@ -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;
- 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;
- 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;
- 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;
diff --git a/server/src/routes/memories/unified.ts b/server/src/routes/memories/unified.ts
deleted file mode 100644
index 5e4b1d2a..00000000
--- a/server/src/routes/memories/unified.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts
deleted file mode 100644
index 9f8e7c27..00000000
--- a/server/src/routes/notifications.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts
deleted file mode 100644
index e5666dc1..00000000
--- a/server/src/routes/oauth.ts
+++ /dev/null
@@ -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();
- 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 = 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 = 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;
- 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 });
-});
\ No newline at end of file
diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts
deleted file mode 100644
index 27831dfe..00000000
--- a/server/src/routes/oidc.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts
deleted file mode 100644
index 7030b674..00000000
--- a/server/src/routes/packing.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/photos.ts b/server/src/routes/photos.ts
deleted file mode 100644
index f2f794b0..00000000
--- a/server/src/routes/photos.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts
deleted file mode 100644
index 3346fa14..00000000
--- a/server/src/routes/places.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/publicConfig.ts b/server/src/routes/publicConfig.ts
deleted file mode 100644
index f39dbb28..00000000
--- a/server/src/routes/publicConfig.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
deleted file mode 100644
index 48e9ef03..00000000
--- a/server/src/routes/reservations.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts
deleted file mode 100644
index fb100c3d..00000000
--- a/server/src/routes/settings.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/share.ts b/server/src/routes/share.ts
deleted file mode 100644
index 9e8145fd..00000000
--- a/server/src/routes/share.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/systemNotices.ts b/server/src/routes/systemNotices.ts
deleted file mode 100644
index 2190963b..00000000
--- a/server/src/routes/systemNotices.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/tags.ts b/server/src/routes/tags.ts
deleted file mode 100644
index 6087f7a7..00000000
--- a/server/src/routes/tags.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/todo.ts b/server/src/routes/todo.ts
deleted file mode 100644
index b8fa6e77..00000000
--- a/server/src/routes/todo.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts
deleted file mode 100644
index 9b2413ba..00000000
--- a/server/src/routes/trips.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts
deleted file mode 100644
index e3ad5280..00000000
--- a/server/src/routes/vacay.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/services/fileService.ts b/server/src/services/fileService.ts
index 8a479ba6..2e7f9d75 100644
--- a/server/src/services/fileService.ts
+++ b/server/src/services/fileService.ts
@@ -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;
}
diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts
index b092084e..3491706a 100644
--- a/server/src/services/journeyService.ts
+++ b/server/src/services/journeyService.ts
@@ -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(
diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts
new file mode 100644
index 00000000..3a7c6309
--- /dev/null
+++ b/server/tests/e2e/addons.e2e.test.ts
@@ -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>;
+
+ 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,
+ },
+ ],
+ },
+ ],
+ });
+ });
+});
diff --git a/server/tests/e2e/memories.e2e.test.ts b/server/tests/e2e/memories.e2e.test.ts
new file mode 100644
index 00000000..708fd837
--- /dev/null
+++ b/server/tests/e2e/memories.e2e.test.ts
@@ -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(
+ '../../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>;
+
+ 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);
+ });
+ });
+});
diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts
index 311cfee5..3261c26b 100644
--- a/server/tests/e2e/reservations.e2e.test.ts
+++ b/server/tests/e2e/reservations.e2e.test.ts
@@ -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));
diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts
index d09497b0..806e8d01 100644
--- a/server/tests/helpers/test-db.ts
+++ b/server/tests/helpers/test-db.ts
@@ -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',
diff --git a/server/tests/integration/admin.test.ts b/server/tests/integration/admin.test.ts
index e96d2234..7ac70848 100644
--- a/server/tests/integration/admin.test.ts
+++ b/server/tests/integration/admin.test.ts
@@ -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();
});
diff --git a/server/tests/integration/assignments.test.ts b/server/tests/integration/assignments.test.ts
index 620c95e7..06c323c6 100644
--- a/server/tests/integration/assignments.test.ts
+++ b/server/tests/integration/assignments.test.ts
@@ -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();
});
diff --git a/server/tests/integration/atlas.test.ts b/server/tests/integration/atlas.test.ts
index da6d25d8..c99c199b 100644
--- a/server/tests/integration/atlas.test.ts
+++ b/server/tests/integration/atlas.test.ts
@@ -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();
});
diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts
index 2eee1f97..6387103e 100644
--- a/server/tests/integration/auth.test.ts
+++ b/server/tests/integration/auth.test.ts
@@ -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();
});
diff --git a/server/tests/integration/backup.test.ts b/server/tests/integration/backup.test.ts
index 183ac2c1..2cc79eec 100644
--- a/server/tests/integration/backup.test.ts
+++ b/server/tests/integration/backup.test.ts
@@ -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();
});
diff --git a/server/tests/integration/bootstrap.test.ts b/server/tests/integration/bootstrap.test.ts
new file mode 100644
index 00000000..53f69d05
--- /dev/null
+++ b/server/tests/integration/bootstrap.test.ts
@@ -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;
+ }
+ });
+});
diff --git a/server/tests/integration/budget.test.ts b/server/tests/integration/budget.test.ts
index 7a1854df..6c1208b9 100644
--- a/server/tests/integration/budget.test.ts
+++ b/server/tests/integration/budget.test.ts
@@ -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();
});
diff --git a/server/tests/integration/categories.test.ts b/server/tests/integration/categories.test.ts
index 105244d2..c03dfe11 100644
--- a/server/tests/integration/categories.test.ts
+++ b/server/tests/integration/categories.test.ts
@@ -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();
});
diff --git a/server/tests/integration/collab.test.ts b/server/tests/integration/collab.test.ts
index f3cce3d8..75de00e9 100644
--- a/server/tests/integration/collab.test.ts
+++ b/server/tests/integration/collab.test.ts
@@ -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();
});
diff --git a/server/tests/integration/dayNotes.test.ts b/server/tests/integration/dayNotes.test.ts
index c3f2845c..f9a02491 100644
--- a/server/tests/integration/dayNotes.test.ts
+++ b/server/tests/integration/dayNotes.test.ts
@@ -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();
});
diff --git a/server/tests/integration/days.test.ts b/server/tests/integration/days.test.ts
index 408b2d51..47fce35a 100644
--- a/server/tests/integration/days.test.ts
+++ b/server/tests/integration/days.test.ts
@@ -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)
diff --git a/server/tests/integration/files.test.ts b/server/tests/integration/files.test.ts
index 8d35b48c..4dd3c909 100644
--- a/server/tests/integration/files.test.ts
+++ b/server/tests/integration/files.test.ts
@@ -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 });
});
diff --git a/server/tests/integration/immich.test.ts b/server/tests/integration/immich.test.ts
index 4ddd82f3..d6c549d2 100644
--- a/server/tests/integration/immich.test.ts
+++ b/server/tests/integration/immich.test.ts
@@ -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();
});
diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts
index 6748992a..3d27388f 100644
--- a/server/tests/integration/journey.test.ts
+++ b/server/tests/integration/journey.test.ts
@@ -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)
diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts
index bad3fa85..47ebaa62 100644
--- a/server/tests/integration/maps.test.ts
+++ b/server/tests/integration/maps.test.ts
@@ -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();
});
diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts
index b8d0bc54..5a59071f 100644
--- a/server/tests/integration/mcp.test.ts
+++ b/server/tests/integration/mcp.test.ts
@@ -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();
});
diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts
index 2b3cdefb..666e0723 100644
--- a/server/tests/integration/memories-immich.test.ts
+++ b/server/tests/integration/memories-immich.test.ts
@@ -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 ─────────────────────────────────────────────────────────
diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts
index 8f0acddf..c277a9ef 100644
--- a/server/tests/integration/memories-synology.test.ts
+++ b/server/tests/integration/memories-synology.test.ts
@@ -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 ──────────────────────────────────────────────────────────────────
diff --git a/server/tests/integration/memories-unified.test.ts b/server/tests/integration/memories-unified.test.ts
index 2d856201..4d3298a6 100644
--- a/server/tests/integration/memories-unified.test.ts
+++ b/server/tests/integration/memories-unified.test.ts
@@ -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('../../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 ──────────────────────────────────────────────────────────────────
diff --git a/server/tests/integration/misc.test.ts b/server/tests/integration/misc.test.ts
index fe0596a7..5b07982b 100644
--- a/server/tests/integration/misc.test.ts
+++ b/server/tests/integration/misc.test.ts
@@ -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 () => {
diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts
index 0eee9763..5d2df558 100644
--- a/server/tests/integration/notifications.test.ts
+++ b/server/tests/integration/notifications.test.ts
@@ -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();
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();
});
diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts
index beeebd9b..4fc861fa 100644
--- a/server/tests/integration/oauth.test.ts
+++ b/server/tests/integration/oauth.test.ts
@@ -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' });
diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts
index a2c1a0d1..017145a8 100644
--- a/server/tests/integration/oidc.test.ts
+++ b/server/tests/integration/oidc.test.ts
@@ -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();
});
diff --git a/server/tests/integration/packing.test.ts b/server/tests/integration/packing.test.ts
index fb2f2247..c749319f 100644
--- a/server/tests/integration/packing.test.ts
+++ b/server/tests/integration/packing.test.ts
@@ -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();
});
diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts
index 45aeac30..f2206a6c 100644
--- a/server/tests/integration/places.test.ts
+++ b/server/tests/integration/places.test.ts
@@ -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();
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();
});
diff --git a/server/tests/integration/profile.test.ts b/server/tests/integration/profile.test.ts
index 5a6b5d24..d77ae780 100644
--- a/server/tests/integration/profile.test.ts
+++ b/server/tests/integration/profile.test.ts
@@ -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();
});
diff --git a/server/tests/integration/reservations.test.ts b/server/tests/integration/reservations.test.ts
index 9412871f..ae7196f7 100644
--- a/server/tests/integration/reservations.test.ts
+++ b/server/tests/integration/reservations.test.ts
@@ -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();
});
diff --git a/server/tests/integration/security.test.ts b/server/tests/integration/security.test.ts
index beda46a8..f5ad1088 100644
--- a/server/tests/integration/security.test.ts
+++ b/server/tests/integration/security.test.ts
@@ -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,35 +43,39 @@ 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 } from '../helpers/factories';
import { authCookie, authHeader, generateToken } from '../helpers/auth';
-import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
-const app: Application = createApp();
+let nestApp: INestApplication;
+let app: Application;
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
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 });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
beforeEach(() => {
resetTestDb(testDb);
- loginAttempts.clear();
- mfaAttempts.clear();
+ resetRateLimits(nestApp);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
-afterAll(() => {
+afterAll(async () => {
+ await nestApp.close();
fs.rmSync(uploadsDir, { recursive: true, force: true });
testDb.close();
});
diff --git a/server/tests/integration/settings.test.ts b/server/tests/integration/settings.test.ts
index d1cecae7..d59c7827 100644
--- a/server/tests/integration/settings.test.ts
+++ b/server/tests/integration/settings.test.ts
@@ -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 } 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();
});
diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts
index d7980efe..032a68db 100644
--- a/server/tests/integration/share.test.ts
+++ b/server/tests/integration/share.test.ts
@@ -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, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } 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();
});
diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts
index 5bc88fae..853d5b3e 100644
--- a/server/tests/integration/systemNotices.test.ts
+++ b/server/tests/integration/systemNotices.test.ts
@@ -5,6 +5,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';
// ─────────────────────────────────────────────────────────────────────────────
// Bare in-memory DB — schema applied in beforeAll after mocks register
@@ -33,9 +34,11 @@ 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';
@@ -44,7 +47,8 @@ import { authCookie } from '../helpers/auth';
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
import type { SystemNotice } from '../../src/systemNotices/types';
-const app: Application = createApp();
+let nestApp: INestApplication;
+let app: Application;
// Test notice injected into the registry for notice-specific tests
const TEST_NOTICE: SystemNotice = {
@@ -59,16 +63,19 @@ const TEST_NOTICE: SystemNotice = {
priority: 0,
};
-beforeAll(() => {
+beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
+ nestApp = await buildApp();
+ app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
});
-afterAll(() => {
+afterAll(async () => {
+ await nestApp.close();
testDb.close();
});
diff --git a/server/tests/integration/tags.test.ts b/server/tests/integration/tags.test.ts
index ee588da2..b0dc7ad9 100644
--- a/server/tests/integration/tags.test.ts
+++ b/server/tests/integration/tags.test.ts
@@ -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 } 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();
});
diff --git a/server/tests/integration/todo.test.ts b/server/tests/integration/todo.test.ts
index 83978723..7a3f25e3 100644
--- a/server/tests/integration/todo.test.ts
+++ b/server/tests/integration/todo.test.ts
@@ -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,32 +31,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, createTrip, addTripMember } 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(() => {
+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();
});
diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts
index 487407cb..acf1c6ab 100644
--- a/server/tests/integration/trips.test.ts
+++ b/server/tests/integration/trips.test.ts
@@ -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,27 +44,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, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote, createDayAssignment } 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();
});
-afterAll(() => { testDb.close(); });
+afterAll(async () => {
+ await nestApp.close();
+ testDb.close();
+});
// ─────────────────────────────────────────────────────────────────────────────
// Create trip (TRIP-001, TRIP-002, TRIP-003)
diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts
index 1ba34e52..043db639 100644
--- a/server/tests/integration/vacay.test.ts
+++ b/server/tests/integration/vacay.test.ts
@@ -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,7 +36,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() }));
// Prevent real HTTP calls (holiday API etc.)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
@@ -56,28 +59,30 @@ vi.mock('../../src/services/vacayService', 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();
vi.unstubAllGlobals();
});
diff --git a/server/tests/parity/l10-todo.parity.test.ts b/server/tests/parity/l10-todo.parity.test.ts
deleted file mode 100644
index 7524c792..00000000
--- a/server/tests/parity/l10-todo.parity.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * S3 parity — to-dos (trip-scoped).
- *
- * Same request at the legacy Express /api/trips/:tripId/todo route (mergeParams)
- * and the migrated Nest controller, with todoService, the permission check, the
- * WebSocket broadcast and auth all mocked identically. Asserts client-identical
- * status + body, including trip 404, permission 403, and the create 201.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser, trip } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
- trip: { id: 5, user_id: 1 },
-}));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { svc } = vi.hoisted(() => ({
- svc: {
- verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(),
- deleteItem: vi.fn(), reorderItems: vi.fn(), getCategoryAssignees: vi.fn(), updateCategoryAssignees: vi.fn(),
- },
-}));
-vi.mock('../../src/services/todoService', () => svc);
-
-import todoRoutes from '../../src/routes/todo';
-import { TodoModule } from '../../src/nest/todo/todo.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('S3 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips/:tripId/todo', todoRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [TodoModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- svc.listItems.mockReturnValue([{ id: 1, name: 'Book hotel' }]);
- svc.createItem.mockReturnValue({ id: 9, name: 'Book hotel' });
- svc.updateItem.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null));
- svc.getCategoryAssignees.mockReturnValue([]);
- });
-
- beforeEach(() => {
- svc.verifyTripAccess.mockReturnValue(trip);
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/todo' }));
-
- it('GET / 404 when trip not accessible', () => {
- svc.verifyTripAccess.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/todo' });
- });
-
- it('POST / create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: { name: 'Book hotel' } }));
-
- it('POST / 403 without permission', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: { name: 'Book hotel' } });
- });
-
- it('POST / 400 missing name', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: {} }));
-
- it('PUT /reorder', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/todo/reorder', body: { orderedIds: [1, 2] } }));
-
- it('PUT /:id 404 when item missing', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/todo/77', body: { name: 'X' } }));
-
- it('GET /category-assignees', () =>
- expectParity(expressServer, nestServer, { path: '/api/trips/5/todo/category-assignees' }));
-});
diff --git a/server/tests/parity/l11-budget.parity.test.ts b/server/tests/parity/l11-budget.parity.test.ts
deleted file mode 100644
index 134c5dbd..00000000
--- a/server/tests/parity/l11-budget.parity.test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * S4 parity — budget (trip-scoped).
- *
- * Same request at the legacy Express /api/trips/:tripId/budget route (mergeParams)
- * and the migrated Nest controller, with budgetService, the permission check, the
- * WebSocket broadcast, the DB and auth all mocked identically. Asserts
- * client-identical status + body across the trip 404, permission 403, validation
- * 400 and the create 201.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser, trip } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
- trip: { id: 5, user_id: 1 },
-}));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { svc } = vi.hoisted(() => ({
- svc: {
- verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
- deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
- calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
- },
-}));
-vi.mock('../../src/services/budgetService', () => svc);
-
-import budgetRoutes from '../../src/routes/budget';
-import { BudgetModule } from '../../src/nest/budget/budget.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('S4 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips/:tripId/budget', budgetRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [BudgetModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- svc.listBudgetItems.mockReturnValue([{ id: 1, name: 'Hotel' }]);
- svc.createBudgetItem.mockReturnValue({ id: 9, name: 'Hotel' });
- svc.updateBudgetItem.mockImplementation((id: string) => (id === '9' ? { id: 9, reservation_id: null, total_price: 100 } : null));
- svc.updateMembers.mockReturnValue({ members: [{ user_id: 2 }], item: { persons: 1 } });
- svc.toggleMemberPaid.mockReturnValue({ user_id: 2, paid: 1 });
- svc.getPerPersonSummary.mockReturnValue([{ userId: 1 }]);
- svc.calculateSettlement.mockReturnValue({ transfers: [] });
- });
-
- beforeEach(() => {
- svc.verifyTripAccess.mockReturnValue(trip);
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget' }));
- it('GET / 404 trip', () => {
- svc.verifyTripAccess.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/budget' });
- });
- it('GET /summary/per-person', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget/summary/per-person' }));
- it('GET /settlement', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget/settlement' }));
- it('POST / create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: { name: 'Hotel' } }));
- it('POST / 403 no permission', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: { name: 'Hotel' } });
- });
- it('POST / 400 missing name', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: {} }));
- it('PUT /reorder/items', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/reorder/items', body: { orderedIds: [1, 2] } }));
- it('PUT /reorder/categories', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/reorder/categories', body: { orderedCategories: ['a'] } }));
- it('PUT /:id 404 when item missing', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/77', body: { name: 'X' } }));
- it('PUT /:id/members 400 not array', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/9/members', body: { user_ids: 'no' } }));
- it('DELETE /:id 404 when missing', () => {
- svc.deleteBudgetItem.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/budget/77' });
- });
-});
diff --git a/server/tests/parity/l12-reservations.parity.test.ts b/server/tests/parity/l12-reservations.parity.test.ts
deleted file mode 100644
index 84036140..00000000
--- a/server/tests/parity/l12-reservations.parity.test.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * S5 parity — reservations + accommodations (trip-scoped).
- *
- * Fires the same request at the legacy Express routes (reservations route +
- * accommodations sub-router from routes/days.ts, both mounted with mergeParams)
- * and the migrated Nest controllers, with the reservation/day/budget services,
- * the permission check, canAccessTrip, the WebSocket broadcast and auth all
- * mocked identically. Asserts client-identical status + body across the trip
- * 404, permission 403, validation 400/404 and the create 201.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser, trip } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
- trip: { id: 5, user_id: 1 },
-}));
-
-const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- canAccessTrip,
- isOwner: vi.fn(() => true),
- getPlaceWithTags: vi.fn(),
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-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(),
- },
- budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() },
- day: {
- listAccommodations: vi.fn(), validateAccommodationRefs: vi.fn(), createAccommodation: vi.fn(),
- getAccommodation: vi.fn(), updateAccommodation: vi.fn(), deleteAccommodation: vi.fn(),
- },
-}));
-vi.mock('../../src/services/reservationService', () => resv);
-vi.mock('../../src/services/budgetService', () => budget);
-vi.mock('../../src/services/dayService', () => day);
-
-import reservationsRoutes from '../../src/routes/reservations';
-import { accommodationsRouter } from '../../src/routes/days';
-import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('S5 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips/:tripId/reservations', reservationsRoutes);
- app.use('/api/trips/:tripId/accommodations', accommodationsRouter);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [ReservationsModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- resv.listReservations.mockReturnValue([{ id: 1, title: 'Hotel' }]);
- resv.createReservation.mockReturnValue({ reservation: { id: 9, title: 'Hotel' }, accommodationCreated: false });
- resv.getReservation.mockImplementation((id: string) => (id === '9' ? { title: 'Hotel', type: 'lodging' } : undefined));
- resv.updateReservation.mockReturnValue({ reservation: { id: 9 }, accommodationChanged: false });
- resv.deleteReservation.mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null });
- day.listAccommodations.mockReturnValue([{ id: 1 }]);
- day.validateAccommodationRefs.mockReturnValue([]);
- day.createAccommodation.mockReturnValue({ id: 9 });
- day.getAccommodation.mockImplementation((id: string) => (id === '9' ? { id: 9 } : undefined));
- day.updateAccommodation.mockReturnValue({ id: 9 });
- day.deleteAccommodation.mockReturnValue({ linkedReservationId: null, deletedBudgetItemId: null });
- });
-
- beforeEach(() => {
- resv.verifyTripAccess.mockReturnValue(trip);
- canAccessTrip.mockReturnValue(trip);
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- // Reservations
- it('GET /reservations', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/reservations' }));
- it('GET /reservations 404 trip', () => {
- resv.verifyTripAccess.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/reservations' });
- });
- it('POST /reservations create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: { title: 'Hotel' } }));
- it('POST /reservations 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: { title: 'Hotel' } });
- });
- it('POST /reservations 400 missing title', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: {} }));
- it('PUT /reservations/positions 400 not array', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/reservations/positions', body: { positions: 'no' } }));
- it('PUT /reservations/:id 404 missing', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/reservations/77', body: { title: 'X' } }));
- it('DELETE /reservations/:id success', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/reservations/9' }));
-
- // Accommodations
- it('GET /accommodations', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/accommodations' }));
- it('GET /accommodations 404 trip', () => {
- canAccessTrip.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/accommodations' });
- });
- it('POST /accommodations create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2, start_day_id: 10, end_day_id: 11 } }));
- it('POST /accommodations 400 missing refs', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2 } }));
- it('POST /accommodations 404 bad ref', () => {
- day.validateAccommodationRefs.mockReturnValue([{ field: 'place_id', message: 'Place not found' }]);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2, start_day_id: 10, end_day_id: 11 } });
- });
- it('PUT /accommodations/:id 404 missing', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/accommodations/77', body: {} }));
- it('DELETE /accommodations/:id success', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/accommodations/9' }));
-});
diff --git a/server/tests/parity/l13-days.parity.test.ts b/server/tests/parity/l13-days.parity.test.ts
deleted file mode 100644
index 695d7f7c..00000000
--- a/server/tests/parity/l13-days.parity.test.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * S6 parity — days + day notes (trip-scoped).
- *
- * Same request at the legacy Express days route + the day-notes route (both
- * mergeParams) and the migrated Nest controllers, with dayService /
- * dayNoteService, the permission check, canAccessTrip, the WebSocket broadcast
- * and auth all mocked identically. Covers trip 404, permission 403, the bespoke
- * 404s, the create 201, and the string-length-before-access ordering.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser, trip } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
- trip: { id: 5, user_id: 1 },
-}));
-
-const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { day, note } = vi.hoisted(() => ({
- day: { listDays: vi.fn(), createDay: vi.fn(), getDay: vi.fn(), updateDay: vi.fn(), deleteDay: vi.fn() },
- note: {
- verifyTripAccess: vi.fn(), listNotes: vi.fn(), dayExists: vi.fn(), createNote: vi.fn(),
- getNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(),
- },
-}));
-vi.mock('../../src/services/dayService', () => day);
-vi.mock('../../src/services/dayNoteService', () => note);
-
-import daysRoutes from '../../src/routes/days';
-import dayNotesRoutes from '../../src/routes/dayNotes';
-import { DaysModule } from '../../src/nest/days/days.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('S6 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
- app.use('/api/trips/:tripId/days', daysRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [DaysModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- day.listDays.mockReturnValue({ days: [{ id: 1 }] });
- day.createDay.mockReturnValue({ id: 9 });
- day.getDay.mockImplementation((id: string) => (id === '9' ? { id: 9 } : undefined));
- day.updateDay.mockReturnValue({ id: 9, title: 'T' });
- note.listNotes.mockReturnValue([{ id: 1 }]);
- note.dayExists.mockReturnValue(true);
- note.createNote.mockReturnValue({ id: 7 });
- note.getNote.mockImplementation((id: string) => (id === '7' ? { id: 7 } : undefined));
- note.updateNote.mockReturnValue({ id: 7 });
- });
-
- beforeEach(() => {
- canAccessTrip.mockReturnValue(trip);
- note.verifyTripAccess.mockReturnValue(trip);
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- // Days
- it('GET /days', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days' }));
- it('GET /days 404 trip', () => {
- canAccessTrip.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/days' });
- });
- it('POST /days create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days', body: { date: '2026-07-01' } }));
- it('POST /days 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days', body: {} });
- });
- it('PUT /days/:id 404 missing', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/77', body: { title: 'X' } }));
- it('DELETE /days/:id 404 missing', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/77' }));
-
- // Day notes
- it('GET notes', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/notes' }));
- it('POST notes create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'Lunch', time: '12:00' } }));
- it('POST notes 400 empty text', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: ' ' } }));
- it('POST notes 400 over-long text (before access)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'x'.repeat(501) } }));
- it('POST notes 404 day not found', () => {
- note.dayExists.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'ok' } });
- });
- it('PUT notes/:id 404 missing', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/3/notes/99', body: { text: 'x' } }));
- it('DELETE notes/:id 404 missing', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/3/notes/99' }));
-});
diff --git a/server/tests/parity/l14-assignments.parity.test.ts b/server/tests/parity/l14-assignments.parity.test.ts
deleted file mode 100644
index 333a6faf..00000000
--- a/server/tests/parity/l14-assignments.parity.test.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * S7 parity — assignments (place↔day itinerary).
- *
- * Same request at the legacy Express assignments route (mounted on /api, with
- * full /trips/... paths) and the migrated Nest controllers, with
- * assignmentService, journeyService.onPlaceCreated, the permission check,
- * canAccessTrip, the WebSocket broadcast and auth all mocked identically. Covers
- * trip 404, permission 403, the bespoke 404s, the create 201 and validation 400.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser, trip } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
- trip: { id: 5, user_id: 1 },
-}));
-
-const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn() }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { asg } = vi.hoisted(() => ({
- asg: {
- getAssignmentWithPlace: vi.fn(), listDayAssignments: vi.fn(), dayExists: vi.fn(), placeExists: vi.fn(),
- createAssignment: vi.fn(), assignmentExistsInDay: vi.fn(), deleteAssignment: vi.fn(), reorderAssignments: vi.fn(),
- getAssignmentForTrip: vi.fn(), moveAssignment: vi.fn(), getParticipants: vi.fn(), updateTime: vi.fn(), setParticipants: vi.fn(),
- },
-}));
-vi.mock('../../src/services/assignmentService', () => asg);
-
-import assignmentsRoutes from '../../src/routes/assignments';
-import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('S7 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api', assignmentsRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [AssignmentsModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- asg.listDayAssignments.mockReturnValue([{ id: 1 }]);
- asg.createAssignment.mockReturnValue({ id: 9 });
- asg.assignmentExistsInDay.mockReturnValue(true);
- asg.getAssignmentForTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, day_id: 3 } : undefined));
- asg.moveAssignment.mockReturnValue({ assignment: { id: 9 } });
- asg.getParticipants.mockReturnValue([{ user_id: 2 }]);
- asg.updateTime.mockReturnValue({ id: 9 });
- asg.setParticipants.mockReturnValue([{ user_id: 2 }]);
- });
-
- beforeEach(() => {
- canAccessTrip.mockReturnValue(trip);
- checkPermission.mockReturnValue(true);
- asg.dayExists.mockReturnValue(true);
- asg.placeExists.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET day-assignments', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/assignments' }));
- it('GET day-assignments 404 day', () => {
- asg.dayExists.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/assignments' });
- });
- it('POST create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 2 } }));
- it('POST 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 2 } });
- });
- it('POST 404 place', () => {
- asg.placeExists.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 99 } });
- });
- it('PUT reorder', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/3/assignments/reorder', body: { orderedIds: [1, 2] } }));
- it('DELETE /:id 404 not in day', () => {
- asg.assignmentExistsInDay.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/3/assignments/77' });
- });
- it('PUT move 404 assignment', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/77/move', body: { new_day_id: 4 } }));
- it('PUT move success', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/move', body: { new_day_id: 4, order_index: 0 } }));
- it('GET participants', () =>
- expectParity(expressServer, nestServer, { path: '/api/trips/5/assignments/9/participants' }));
- it('PUT time success', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/time', body: { place_time: '10:00' } }));
- it('PUT participants 400 not array', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/participants', body: { user_ids: 'no' } }));
-});
diff --git a/server/tests/parity/l15-places.parity.test.ts b/server/tests/parity/l15-places.parity.test.ts
deleted file mode 100644
index 2d0ad23e..00000000
--- a/server/tests/parity/l15-places.parity.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-/**
- * S8 parity — places (trip-scoped).
- *
- * Same request at the legacy Express /api/trips/:tripId/places route (mergeParams)
- * and the migrated Nest controller, with placeService, journeyService, the
- * permission check, canAccessTrip, the WebSocket broadcast and auth mocked
- * identically. Covers the JSON endpoints (the multer file imports are covered by
- * the controller unit test): trip 404, length 400, permission 403, name 400,
- * list-import error mapping, bulk-delete validation, and the create 201.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser, trip } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
- trip: { id: 5, user_id: 1 },
-}));
-
-const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { pl } = vi.hoisted(() => ({
- pl: {
- listPlaces: vi.fn(), createPlace: vi.fn(), getPlace: vi.fn(), updatePlace: vi.fn(), deletePlace: vi.fn(),
- deletePlacesMany: vi.fn(), importGpx: vi.fn(), importMapFile: vi.fn(), importGoogleList: vi.fn(),
- importNaverList: vi.fn(), searchPlaceImage: vi.fn(),
- },
-}));
-vi.mock('../../src/services/placeService', () => pl);
-
-import placesRoutes from '../../src/routes/places';
-import { PlacesModule } from '../../src/nest/places/places.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('S8 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips/:tripId/places', placesRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [PlacesModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- pl.listPlaces.mockReturnValue([{ id: 1, name: 'Spot' }]);
- pl.createPlace.mockReturnValue({ id: 9, name: 'Spot' });
- pl.getPlace.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : undefined));
- pl.updatePlace.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null));
- pl.deletePlace.mockImplementation((_t: string, id: string) => id === '9');
- pl.deletePlacesMany.mockReturnValue([1, 2]);
- pl.importGoogleList.mockResolvedValue({ places: [{ id: 1 }], listName: 'L', skipped: 0 });
- pl.importNaverList.mockResolvedValue({ error: 'List is empty', status: 400 });
- pl.searchPlaceImage.mockResolvedValue({ photos: [{ url: 'x' }] });
- });
-
- beforeEach(() => {
- canAccessTrip.mockReturnValue(trip);
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/places', query: { search: 'sp' } }));
- it('GET / 404 trip', () => {
- canAccessTrip.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/places' });
- });
- it('POST / create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'Spot' } }));
- it('POST / 400 over-long name', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'x'.repeat(201) } }));
- it('POST / 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'Spot' } });
- });
- it('POST / 400 missing name', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: {} }));
- it('POST /import/google-list success (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/google-list', body: { url: 'http://x' } }));
- it('POST /import/google-list 400 missing url', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/google-list', body: {} }));
- it('POST /import/naver-list service error', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/naver-list', body: { url: 'http://x' } }));
- it('POST /bulk-delete 400 not numbers', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: ['a'] } }));
- it('POST /bulk-delete empty', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: [] } }));
- it('POST /bulk-delete success', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: [1, 2] } }));
- it('GET /:id 404', () =>
- expectParity(expressServer, nestServer, { path: '/api/trips/5/places/77' }));
- it('GET /:id found', () =>
- expectParity(expressServer, nestServer, { path: '/api/trips/5/places/9' }));
- it('GET /:id/image', () =>
- expectParity(expressServer, nestServer, { path: '/api/trips/5/places/9/image' }));
- it('PUT /:id 404', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/places/77', body: { name: 'X' } }));
- it('DELETE /:id success', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/places/9' }));
-});
diff --git a/server/tests/parity/l16-trips.parity.test.ts b/server/tests/parity/l16-trips.parity.test.ts
deleted file mode 100644
index a894b057..00000000
--- a/server/tests/parity/l16-trips.parity.test.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * C1 parity — trips aggregate root.
- *
- * Same request at the legacy Express /api/trips route and the migrated Nest
- * controller, with tripService, the bundle list-services, auditLog, demo,
- * the permission check, the WebSocket broadcast and auth mocked identically.
- * Covers the own-routes (list/create/get/update/delete/members/copy/bundle);
- * the exact-prefix routing (not capturing collab/files) is unit-tested in the
- * strangler spec.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
-
-const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => ({ id: 42 }), all: () => [], run: () => undefined }) },
- canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() }));
-vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { tripSvc } = vi.hoisted(() => ({
- tripSvc: {
- listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(),
- getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(),
- listMembers: vi.fn(), addMember: vi.fn(), removeMember: vi.fn(), exportICS: vi.fn(), copyTripById: vi.fn(),
- verifyTripAccess: vi.fn(), NotFoundError: class NotFoundError extends Error {}, ValidationError: class ValidationError extends Error {},
- TRIP_SELECT: 'SELECT * FROM trips t',
- },
-}));
-vi.mock('../../src/services/tripService', () => tripSvc);
-// Bundle list-services — return empty collections.
-vi.mock('../../src/services/dayService', () => ({ listDays: () => ({ days: [] }), listAccommodations: () => [] }));
-vi.mock('../../src/services/placeService', () => ({ listPlaces: () => [] }));
-vi.mock('../../src/services/packingService', () => ({ listItems: () => [] }));
-vi.mock('../../src/services/todoService', () => ({ listItems: () => [] }));
-vi.mock('../../src/services/budgetService', () => ({ listBudgetItems: () => [] }));
-vi.mock('../../src/services/reservationService', () => ({ listReservations: () => [] }));
-vi.mock('../../src/services/fileService', () => ({ listFiles: () => [] }));
-
-import tripsRoutes from '../../src/routes/trips';
-import { TripsModule } from '../../src/nest/trips/trips.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('C1 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips', tripsRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [TripsModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- tripSvc.listTrips.mockReturnValue([{ id: 1, title: 'T' }]);
- tripSvc.createTrip.mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
- tripSvc.getTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, user_id: 1 } : undefined));
- tripSvc.updateTrip.mockReturnValue({ updatedTrip: { id: 9 }, changes: {}, newTitle: 'T', newReminder: 0, oldReminder: 0 });
- tripSvc.getTripOwner.mockReturnValue({ user_id: 1 });
- tripSvc.deleteTrip.mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: false });
- tripSvc.listMembers.mockReturnValue({ owner: { id: 1 }, members: [] });
- tripSvc.copyTripById.mockReturnValue(42);
- });
-
- beforeEach(() => {
- canAccessTrip.mockReturnValue({ user_id: 1 });
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/trips' }));
- it('POST / create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T' } }));
- it('POST / 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T' } });
- });
- it('POST / 400 missing title', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: {} }));
- it('POST / 400 end before start', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T', start_date: '2026-07-10', end_date: '2026-07-01' } }));
- it('GET /:id found', () => expectParity(expressServer, nestServer, { path: '/api/trips/9' }));
- it('GET /:id 404', () => {
- tripSvc.getTrip.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/77' });
- });
- it('PUT /:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/9', body: { title: 'b' } }));
- it('PUT /:id 404 no access', () => {
- canAccessTrip.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/9', body: { title: 'b' } });
- });
- it('POST /:id/copy (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/9/copy', body: { title: 'Copy' } }));
- it('DELETE /:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/9' }));
- it('GET /:id/members', () => expectParity(expressServer, nestServer, { path: '/api/trips/9/members' }));
- it('POST /:id/members (201)', () => {
- tripSvc.addMember.mockReturnValue({ member: { id: 2, email: 'b@x.y' }, targetUserId: 2, tripTitle: 'T' });
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/9/members', body: { identifier: 'b@x.y' } });
- });
- it('GET /:id/bundle', () => expectParity(expressServer, nestServer, { path: '/api/trips/9/bundle' }));
-});
diff --git a/server/tests/parity/l17-collab.parity.test.ts b/server/tests/parity/l17-collab.parity.test.ts
deleted file mode 100644
index fa224185..00000000
--- a/server/tests/parity/l17-collab.parity.test.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-/**
- * C2 parity — collab (shared notes, polls, chat + reactions, link previews).
- *
- * Same request at the legacy Express /api/trips/:tripId/collab route and the
- * migrated Nest controller, with collabService, permissions, the WebSocket
- * broadcast, the notification fire-and-forget, the db and auth mocked
- * identically. File uploads are exercised by the e2e/unit specs (multer differs
- * per framework); this pins routing, status codes, the error envelopes and the
- * poll/message error-string mapping.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => ({ title: 'T' }), all: () => [], run: () => undefined }) },
- canAccessTrip: vi.fn(() => ({ user_id: 1 })), closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { collabSvc } = vi.hoisted(() => ({
- collabSvc: {
- verifyTripAccess: vi.fn(), listNotes: vi.fn(), createNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(),
- addNoteFile: vi.fn(), getFormattedNoteById: vi.fn(), deleteNoteFile: vi.fn(),
- listPolls: vi.fn(), createPoll: vi.fn(), votePoll: vi.fn(), closePoll: vi.fn(), deletePoll: vi.fn(),
- listMessages: vi.fn(), createMessage: vi.fn(), deleteMessage: vi.fn(), addOrRemoveReaction: vi.fn(), fetchLinkPreview: vi.fn(),
- },
-}));
-vi.mock('../../src/services/collabService', () => collabSvc);
-
-import collabRoutes from '../../src/routes/collab';
-import { CollabModule } from '../../src/nest/collab/collab.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('C2 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips/:tripId/collab', collabRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [CollabModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- collabSvc.listNotes.mockReturnValue([{ id: 1, title: 'N' }]);
- collabSvc.createNote.mockReturnValue({ id: 9, title: 'N' });
- collabSvc.updateNote.mockReturnValue({ id: 9, title: 'N2' });
- collabSvc.deleteNote.mockReturnValue(true);
- collabSvc.listPolls.mockReturnValue([{ id: 1 }]);
- collabSvc.createPoll.mockReturnValue({ id: 7 });
- collabSvc.votePoll.mockReturnValue({ poll: { id: 7 } });
- collabSvc.closePoll.mockReturnValue({ id: 7, closed: 1 });
- collabSvc.deletePoll.mockReturnValue(true);
- collabSvc.listMessages.mockReturnValue([{ id: 1, text: 'hi' }]);
- collabSvc.createMessage.mockReturnValue({ message: { id: 3, text: 'hi' } });
- collabSvc.deleteMessage.mockReturnValue({ username: 'u' });
- collabSvc.addOrRemoveReaction.mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] });
- collabSvc.fetchLinkPreview.mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' });
- });
-
- beforeEach(() => {
- collabSvc.verifyTripAccess.mockReturnValue({ user_id: 1 });
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- // Notes
- it('GET /notes', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/notes' }));
- it('GET /notes 404 no access', () => {
- collabSvc.verifyTripAccess.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/notes' });
- });
- it('POST /notes (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: { title: 'N' } }));
- it('POST /notes 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: { title: 'N' } });
- });
- it('POST /notes 400 missing title', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: {} }));
- it('PUT /notes/:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/notes/9', body: { title: 'N2' } }));
- it('PUT /notes/:id 404', () => {
- collabSvc.updateNote.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/notes/9', body: { title: 'x' } });
- });
- it('DELETE /notes/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/notes/9' }));
- it('DELETE /notes/:id 404', () => {
- collabSvc.deleteNote.mockReturnValueOnce(false).mockReturnValueOnce(false);
- return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/notes/9' });
- });
-
- // Polls
- it('GET /polls', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/polls' }));
- it('POST /polls (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { question: 'q', options: ['a', 'b'] } }));
- it('POST /polls 400 missing question', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { options: ['a', 'b'] } }));
- it('POST /polls 400 too few options', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { question: 'q', options: ['a'] } }));
- it('POST /polls/:id/vote (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } }));
- it('POST /polls/:id/vote 404', () => {
- collabSvc.votePoll.mockReturnValueOnce({ error: 'not_found' }).mockReturnValueOnce({ error: 'not_found' });
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } });
- });
- it('POST /polls/:id/vote 400 closed', () => {
- collabSvc.votePoll.mockReturnValueOnce({ error: 'closed' }).mockReturnValueOnce({ error: 'closed' });
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } });
- });
- it('PUT /polls/:id/close', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/polls/7/close' }));
- it('DELETE /polls/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/polls/7' }));
-
- // Messages
- it('GET /messages', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/messages' }));
- it('POST /messages (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'hi' } }));
- it('POST /messages 400 too long', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'x'.repeat(5001) } }));
- it('POST /messages 400 empty', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: ' ' } }));
- it('POST /messages 400 reply_not_found', () => {
- collabSvc.createMessage.mockReturnValueOnce({ error: 'reply_not_found' }).mockReturnValueOnce({ error: 'reply_not_found' });
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'hi', reply_to: 99 } });
- });
- it('POST /messages/:id/react (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages/3/react', body: { emoji: '👍' } }));
- it('POST /messages/:id/react 404', () => {
- collabSvc.addOrRemoveReaction.mockReturnValueOnce({ found: false, reactions: [] }).mockReturnValueOnce({ found: false, reactions: [] });
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages/3/react', body: { emoji: '👍' } });
- });
- it('DELETE /messages/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/messages/3' }));
- it('DELETE /messages/:id 403 not owner', () => {
- collabSvc.deleteMessage.mockReturnValueOnce({ error: 'not_owner' }).mockReturnValueOnce({ error: 'not_owner' });
- return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/messages/3' });
- });
-
- // Link preview
- it('GET /link-preview', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/link-preview', query: { url: 'http://x' } }));
- it('GET /link-preview 400 missing url', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/link-preview' }));
-});
diff --git a/server/tests/parity/l18-files.parity.test.ts b/server/tests/parity/l18-files.parity.test.ts
deleted file mode 100644
index be70acc1..00000000
--- a/server/tests/parity/l18-files.parity.test.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/**
- * C3 parity — files (trip file manager) + photos (global photo access).
- *
- * Same request at the legacy Express routes and the migrated Nest controllers,
- * with the file/photo services, permissions, the WebSocket broadcast, demo and
- * auth mocked identically. Multipart upload + the sendFile/stream success bodies
- * differ per framework (multer vs FileInterceptor, res.sendFile), so this pins
- * routing, status codes and the JSON error envelopes — including the unguarded
- * download's token-auth errors and the photo id/access guards.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => ({ id: 42 }), all: () => [], run: () => undefined }) },
- canAccessTrip: vi.fn(() => ({ user_id: 1 })), closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-vi.mock('../../src/middleware/tripAccess', () => ({
- requireTripAccess: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { trip: unknown }).trip = { user_id: 1 };
- next();
- },
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { fileSvc } = vi.hoisted(() => ({
- fileSvc: {
- // Constants the route + controller read at import time.
- MAX_FILE_SIZE: 50 * 1024 * 1024,
- BLOCKED_EXTENSIONS: ['.exe', '.svg'],
- filesDir: '/tmp/files',
- getAllowedExtensions: () => '*',
- verifyTripAccess: vi.fn(), formatFile: vi.fn(), resolveFilePath: vi.fn(), authenticateDownload: vi.fn(),
- listFiles: vi.fn(), getFileById: vi.fn(), getFileByIdFull: vi.fn(), getDeletedFile: vi.fn(),
- createFile: vi.fn(), updateFile: vi.fn(), toggleStarred: vi.fn(), softDeleteFile: vi.fn(),
- restoreFile: vi.fn(), permanentDeleteFile: vi.fn(), emptyTrash: vi.fn(), createFileLink: vi.fn(),
- deleteFileLink: vi.fn(), getFileLinks: vi.fn(),
- },
-}));
-vi.mock('../../src/services/fileService', () => fileSvc);
-
-vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
-
-const { photoSvc, helperSvc } = vi.hoisted(() => ({
- photoSvc: { streamPhoto: vi.fn(), getPhotoInfo: vi.fn(), resolveTrekPhoto: vi.fn() },
- helperSvc: { canAccessTrekPhoto: vi.fn() },
-}));
-vi.mock('../../src/services/memories/photoResolverService', () => photoSvc);
-vi.mock('../../src/services/memories/helpersService', () => helperSvc);
-
-import filesRoutes from '../../src/routes/files';
-import photosRoutes from '../../src/routes/photos';
-import { FilesModule } from '../../src/nest/files/files.module';
-import { PhotosModule } from '../../src/nest/photos/photos.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('C3 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips/:tripId/files', filesRoutes);
- app.use('/api/photos', photosRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [FilesModule, PhotosModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- fileSvc.listFiles.mockReturnValue([{ id: 1, original_name: 'a.pdf' }]);
- fileSvc.getFileById.mockReturnValue({ id: 9, starred: 0, description: 'x' });
- fileSvc.getDeletedFile.mockReturnValue({ id: 9 });
- fileSvc.updateFile.mockReturnValue({ id: 9, description: 'new' });
- fileSvc.toggleStarred.mockReturnValue({ id: 9, starred: 1 });
- fileSvc.restoreFile.mockReturnValue({ id: 9 });
- fileSvc.permanentDeleteFile.mockResolvedValue(undefined);
- fileSvc.emptyTrash.mockResolvedValue(2);
- fileSvc.createFileLink.mockReturnValue([{ id: 1 }]);
- fileSvc.getFileLinks.mockReturnValue([{ id: 1 }]);
- fileSvc.authenticateDownload.mockReturnValue({ error: 'Authentication required', status: 401 });
- });
-
- beforeEach(() => {
- fileSvc.verifyTripAccess.mockReturnValue({ user_id: 1 });
- checkPermission.mockReturnValue(true);
- helperSvc.canAccessTrekPhoto.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- // Files — JSON endpoints
- it('GET /files', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files' }));
- it('GET /files?trash=true', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files', query: { trash: 'true' } }));
- it('GET /files 404 no access', () => {
- fileSvc.verifyTripAccess.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/files' });
- });
- it('PUT /files/:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: { description: 'new' } }));
- it('PUT /files/:id 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: { description: 'x' } });
- });
- it('PUT /files/:id 404', () => {
- fileSvc.getFileById.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
- return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: {} });
- });
- it('PATCH /files/:id/star', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/trips/5/files/9/star' }));
- it('DELETE /files/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9' }));
- it('POST /files/:id/restore (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/restore' }));
- it('POST /files/:id/restore 404 not in trash', () => {
- fileSvc.getDeletedFile.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/restore' });
- });
- it('DELETE /files/:id/permanent', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9/permanent' }));
- it('DELETE /files/trash/empty', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/trash/empty' }));
- it('POST /files/:id/link (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/link', body: { reservation_id: 2 } }));
- it('DELETE /files/:id/link/:linkId', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9/link/3' }));
- it('GET /files/:id/links', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/links' }));
-
- // Files — download (unguarded), error paths only (sendFile body differs)
- it('GET /files/:id/download 401 (token)', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/download' }));
- it('GET /files/:id/download 404 no access', () => {
- fileSvc.authenticateDownload.mockReturnValue({ userId: 1 });
- fileSvc.verifyTripAccess.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/download' });
- });
-
- // Photos — guard paths only (stream/info success writes binary/json via res)
- it('GET /photos/:id/thumbnail 400 invalid id', () => expectParity(expressServer, nestServer, { path: '/api/photos/abc/thumbnail' }));
- it('GET /photos/:id/original 403 no access', () => {
- helperSvc.canAccessTrekPhoto.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { path: '/api/photos/5/original' });
- });
- it('GET /photos/:id/info 403 no access', () => {
- helperSvc.canAccessTrekPhoto.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { path: '/api/photos/5/info' });
- });
-});
diff --git a/server/tests/parity/l19-journey.parity.test.ts b/server/tests/parity/l19-journey.parity.test.ts
deleted file mode 100644
index a5cc9a02..00000000
--- a/server/tests/parity/l19-journey.parity.test.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-/**
- * C4 parity — journey (authenticated) + public journey share.
- *
- * Same request at the legacy Express routes and the migrated Nest controllers,
- * with journeyService, journeyShareService, the addon gate, db and auth mocked
- * identically. Multipart photo uploads + the stream/sendFile success bodies
- * differ per framework, so this pins routing, the addon-gate 404, status codes
- * (create 201 vs cover/trips/share 200 vs unlink 204) and the JSON envelopes.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => ({ immich_auto_upload: 0 }), all: () => [], run: () => undefined }) },
- closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
-vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
-vi.mock('../../src/services/fileService', () => ({ getAllowedExtensions: () => '*' }));
-vi.mock('../../src/services/memories/immichService', () => ({ uploadToImmich: vi.fn(), streamImmichAsset: vi.fn() }));
-vi.mock('../../src/services/memories/photoResolverService', () => ({ streamPhoto: vi.fn() }));
-
-const { jsvc } = vi.hoisted(() => ({
- jsvc: {
- canAccessJourney: vi.fn(), isOwner: vi.fn(), canEdit: vi.fn(),
- listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(), updateJourney: vi.fn(),
- updateJourneyPreferences: vi.fn(), deleteJourney: vi.fn(), addTripToJourney: vi.fn(), removeTripFromJourney: vi.fn(),
- listEntries: vi.fn(), createEntry: vi.fn(), updateEntry: vi.fn(), reorderEntries: vi.fn(), deleteEntry: vi.fn(),
- addPhoto: vi.fn(), addProviderPhoto: vi.fn(), linkPhotoToEntry: vi.fn(), uploadGalleryPhotos: vi.fn(),
- addProviderPhotoToGallery: vi.fn(), unlinkPhotoFromEntry: vi.fn(), deleteGalleryPhoto: vi.fn(), setPhotoProvider: vi.fn(),
- updatePhoto: vi.fn(), deletePhoto: vi.fn(), addContributor: vi.fn(), updateContributorRole: vi.fn(), removeContributor: vi.fn(),
- getSuggestions: vi.fn(), listUserTrips: vi.fn(),
- },
-}));
-vi.mock('../../src/services/journeyService', () => jsvc);
-
-const { sharesvc } = vi.hoisted(() => ({
- sharesvc: {
- createOrUpdateJourneyShareLink: vi.fn(), getJourneyShareLink: vi.fn(), deleteJourneyShareLink: vi.fn(),
- getPublicJourney: vi.fn(), validateShareTokenForPhoto: vi.fn(), validateShareTokenForAsset: vi.fn(),
- },
-}));
-vi.mock('../../src/services/journeyShareService', () => sharesvc);
-
-import journeyRoutes from '../../src/routes/journey';
-import journeyPublicRoutes from '../../src/routes/journeyPublic';
-import { JourneyModule } from '../../src/nest/journey/journey.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-import { ADDON_IDS } from '../../src/addons';
-
-describe('C4 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- // Mirror the app.ts mount gate so both stacks 404 when the addon is off.
- 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);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [JourneyModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]);
- jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' });
- jsvc.getSuggestions.mockReturnValue([{ id: 1 }]);
- jsvc.listUserTrips.mockReturnValue([{ id: 2 }]);
- jsvc.getJourneyFull.mockReturnValue({ id: 9, title: 'J' });
- jsvc.updateJourney.mockReturnValue({ id: 9, title: 'J2' });
- jsvc.deleteJourney.mockReturnValue(true);
- jsvc.addTripToJourney.mockReturnValue(true);
- jsvc.removeTripFromJourney.mockReturnValue(true);
- jsvc.listEntries.mockReturnValue([{ id: 1 }]);
- jsvc.createEntry.mockReturnValue({ id: 3 });
- jsvc.updateEntry.mockReturnValue({ id: 3 });
- jsvc.deleteEntry.mockReturnValue(true);
- jsvc.reorderEntries.mockReturnValue(true);
- jsvc.addProviderPhoto.mockReturnValue({ id: 5 });
- jsvc.linkPhotoToEntry.mockReturnValue({ id: 5 });
- jsvc.addProviderPhotoToGallery.mockReturnValue({ id: 5 });
- jsvc.unlinkPhotoFromEntry.mockReturnValue(true);
- jsvc.deleteGalleryPhoto.mockReturnValue({ id: 5, file_path: null });
- jsvc.updatePhoto.mockReturnValue({ id: 5 });
- jsvc.deletePhoto.mockReturnValue({ id: 5, file_path: null });
- jsvc.addContributor.mockReturnValue(true);
- jsvc.updateContributorRole.mockReturnValue(true);
- jsvc.removeContributor.mockReturnValue(true);
- jsvc.updateJourneyPreferences.mockReturnValue({ ok: true });
- sharesvc.getJourneyShareLink.mockReturnValue({ token: 'abc' });
- sharesvc.createOrUpdateJourneyShareLink.mockReturnValue({ token: 'abc' });
- sharesvc.deleteJourneyShareLink.mockReturnValue(true);
- sharesvc.getPublicJourney.mockReturnValue({ id: 9 });
- });
-
- beforeEach(() => {
- isAddonEnabled.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('404 when the Journey addon is disabled', () => {
- isAddonEnabled.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { path: '/api/journeys' });
- });
-
- it('GET /journeys', () => expectParity(expressServer, nestServer, { path: '/api/journeys' }));
- it('POST /journeys (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys', body: { title: 'J' } }));
- it('POST /journeys 400 no title', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys', body: {} }));
- it('GET /journeys/suggestions', () => expectParity(expressServer, nestServer, { path: '/api/journeys/suggestions' }));
- it('GET /journeys/available-trips', () => expectParity(expressServer, nestServer, { path: '/api/journeys/available-trips' }));
-
- it('PATCH /journeys/entries/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/entries/3', body: { title: 'x' } }));
- it('PATCH /journeys/entries/:id 404', () => {
- jsvc.updateEntry.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/entries/3', body: {} });
- });
- it('DELETE /journeys/entries/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/entries/3' }));
- it('POST /journeys/entries/:id/provider-photos batch', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/provider-photos', body: { provider: 'immich', asset_ids: ['a', 'b'] } }));
- it('POST /journeys/entries/:id/provider-photos 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/provider-photos', body: { provider: 'immich' } }));
- it('POST /journeys/entries/:id/link-photo (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/link-photo', body: { journey_photo_id: 5 } }));
- it('POST /journeys/entries/:id/link-photo 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/link-photo', body: {} }));
- it('DELETE /journeys/entries/:id/photos/:pid (204)', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/entries/3/photos/7' }));
- it('PATCH /journeys/photos/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/photos/5', body: { caption: 'c' } }));
- it('DELETE /journeys/photos/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/photos/5' }));
-
- it('POST /journeys/:id/gallery/provider-photos batch', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/gallery/provider-photos', body: { provider: 'immich', asset_ids: ['a'] } }));
- it('DELETE /journeys/:id/gallery/:pid (204)', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/gallery/7' }));
-
- it('GET /journeys/:id', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9' }));
- it('GET /journeys/:id 404', () => {
- jsvc.getJourneyFull.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(expressServer, nestServer, { path: '/api/journeys/9' });
- });
- it('PATCH /journeys/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9', body: { title: 'J2' } }));
- it('DELETE /journeys/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9' }));
-
- it('POST /journeys/:id/trips (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/trips', body: { trip_id: 2 } }));
- it('POST /journeys/:id/trips 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/trips', body: {} }));
- it('DELETE /journeys/:id/trips/:tripId', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/trips/2' }));
-
- it('GET /journeys/:id/entries', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9/entries' }));
- it('POST /journeys/:id/entries (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/entries', body: { entry_date: '2026-01-01' } }));
- it('POST /journeys/:id/entries 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/entries', body: {} }));
- it('PUT /journeys/:id/entries/reorder', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/journeys/9/entries/reorder', body: { orderedIds: [1, 2] } }));
- it('PUT /journeys/:id/entries/reorder 400', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/journeys/9/entries/reorder', body: { orderedIds: 'x' } }));
-
- it('POST /journeys/:id/contributors (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/contributors', body: { user_id: 2 } }));
- it('POST /journeys/:id/contributors 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/contributors', body: {} }));
- it('PATCH /journeys/:id/contributors/:uid', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9/contributors/2', body: { role: 'editor' } }));
- it('DELETE /journeys/:id/contributors/:uid', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/contributors/2' }));
-
- it('PATCH /journeys/:id/preferences', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9/preferences', body: { theme: 'dark' } }));
- it('GET /journeys/:id/share-link', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9/share-link' }));
- it('POST /journeys/:id/share-link (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/share-link', body: { share_timeline: true } }));
- it('DELETE /journeys/:id/share-link', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/share-link' }));
-
- // Public
- it('GET /public/journey/:token', () => expectParity(expressServer, nestServer, { path: '/api/public/journey/tok' }));
- it('GET /public/journey/:token 404', () => {
- sharesvc.getPublicJourney.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(expressServer, nestServer, { path: '/api/public/journey/tok' });
- });
-});
diff --git a/server/tests/parity/l2.parity.test.ts b/server/tests/parity/l2.parity.test.ts
deleted file mode 100644
index bdf822d8..00000000
--- a/server/tests/parity/l2.parity.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * L2 parity — airports + public config + system notices.
- *
- * Fires the same request at the legacy Express routes and the migrated Nest
- * controllers with the shared services mocked identically for both, then asserts
- * the responses are client-identical (status + body). This is the gate before
- * the prefixes are flipped to Nest: any difference here is a framework-layer
- * regression (routing, error envelope, status), which a migration must not cause.
- *
- * Auth is neutralised the same way for both apps — `verifyJwtAndLoadUser` /
- * `extractToken` are stubbed so the real Nest guard and the Express middleware
- * both authenticate the same fixed user. Auth behaviour itself is covered by the
- * per-module e2e tests.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'parity', email: 'parity@example.test', role: 'user' },
-}));
-
-// The services under test are mocked below, so no real DB is needed. Stubbing
-// the connection keeps the legacy database.ts init (and its lazy backfill
-// require) out of the parity run, which otherwise clashes with the mocked
-// airportService module.
-vi.mock('../../src/db/database', () => ({ db: {}, closeDb: () => {}, reinitialize: () => {} }));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'parity-token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { mockSearch, mockFindByIata } = vi.hoisted(() => ({ mockSearch: vi.fn(), mockFindByIata: vi.fn() }));
-vi.mock('../../src/services/airportService', async (importActual) => {
- const actual = await importActual();
- return { ...actual, searchAirports: mockSearch, findByIata: mockFindByIata };
-});
-
-const { mockGetActive, mockDismiss } = vi.hoisted(() => ({ mockGetActive: vi.fn(), mockDismiss: vi.fn() }));
-vi.mock('../../src/systemNotices/service', () => ({
- getActiveNoticesFor: mockGetActive,
- dismissNotice: mockDismiss,
-}));
-
-import airportsRoutes from '../../src/routes/airports';
-import publicConfigRoutes from '../../src/routes/publicConfig';
-import systemNoticesRoutes from '../../src/routes/systemNotices';
-import { AirportsModule } from '../../src/nest/airports/airports.module';
-import { ConfigModule } from '../../src/nest/config/config.module';
-import { SystemNoticesModule } from '../../src/nest/system-notices/system-notices.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-const BER = {
- iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin',
- country: 'DE', lat: 52.36, lng: 13.5, tz: 'Europe/Berlin',
-};
-const notice = {
- id: 'welcome', display: 'modal', severity: 'info',
- titleKey: 'notice.welcome.title', bodyKey: 'notice.welcome.body', dismissible: true,
-};
-
-describe('L2 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/airports', airportsRoutes);
- app.use('/api/config', publicConfigRoutes);
- app.use('/api/system-notices', systemNoticesRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({
- imports: [AirportsModule, ConfigModule, SystemNoticesModule],
- }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- mockSearch.mockReturnValue([BER]);
- mockFindByIata.mockImplementation((code: string) => (code === 'BER' ? BER : null));
- mockGetActive.mockReturnValue([notice]);
- mockDismiss.mockImplementation((_userId: number, id: string) => id === 'welcome');
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /api/airports/search with a query', () =>
- expectParity(expressServer, nestServer, { path: '/api/airports/search', query: { q: 'ber' } }));
-
- it('GET /api/airports/search without a query', () =>
- expectParity(expressServer, nestServer, { path: '/api/airports/search' }));
-
- it('GET /api/airports/:iata found', () =>
- expectParity(expressServer, nestServer, { path: '/api/airports/BER' }));
-
- it('GET /api/airports/:iata not found (404)', () =>
- expectParity(expressServer, nestServer, { path: '/api/airports/ZZZ' }));
-
- it('GET /api/config (public)', () =>
- expectParity(expressServer, nestServer, { path: '/api/config' }));
-
- it('GET /api/system-notices/active', () =>
- expectParity(expressServer, nestServer, { path: '/api/system-notices/active' }));
-
- it('POST /api/system-notices/:id/dismiss success (204)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/system-notices/welcome/dismiss' }));
-
- it('POST /api/system-notices/:id/dismiss not found (404)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/system-notices/nope/dismiss' }));
-});
diff --git a/server/tests/parity/l20-share.parity.test.ts b/server/tests/parity/l20-share.parity.test.ts
deleted file mode 100644
index c4ae28fc..00000000
--- a/server/tests/parity/l20-share.parity.test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * C5 parity — trip share links + the public shared-trip read.
- *
- * Same request at the legacy Express /api route and the migrated Nest
- * controllers, with shareService, the permission check, the trip-access lookup
- * and auth mocked identically. Pins routing, trip-access 404, permission 403,
- * the create-201-vs-update-200 split and the unguarded public 404/JSON.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
-
-const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- canAccessTrip, closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { shareSvc } = vi.hoisted(() => ({
- shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn() },
-}));
-vi.mock('../../src/services/shareService', () => shareSvc);
-
-import shareRoutes from '../../src/routes/share';
-import { ShareModule } from '../../src/nest/share/share.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('C5 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api', shareRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [ShareModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- shareSvc.getShareLink.mockReturnValue({ token: 't', share_map: 1 });
- shareSvc.getSharedTripData.mockReturnValue({ trip: { id: 9 } });
- });
-
- beforeEach(() => {
- canAccessTrip.mockReturnValue({ user_id: 1 });
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('POST /trips/:id/share-link (201 created)', () => {
- shareSvc.createOrUpdateShareLink.mockReturnValue({ token: 't', created: true });
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: { share_map: true } });
- });
- it('POST /trips/:id/share-link (200 update)', () => {
- shareSvc.createOrUpdateShareLink.mockReturnValue({ token: 't', created: false });
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} });
- });
- it('POST /trips/:id/share-link 404 no access', () => {
- canAccessTrip.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} });
- });
- it('POST /trips/:id/share-link 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} });
- });
- it('GET /trips/:id/share-link', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' }));
- it('GET /trips/:id/share-link null token', () => {
- shareSvc.getShareLink.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' });
- });
- it('GET /trips/:id/share-link 404 no access', () => {
- canAccessTrip.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' });
- });
- it('DELETE /trips/:id/share-link', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/share-link' }));
- it('DELETE /trips/:id/share-link 403', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/share-link' });
- });
-
- it('GET /shared/:token', () => expectParity(expressServer, nestServer, { path: '/api/shared/tok' }));
- it('GET /shared/:token 404', () => {
- shareSvc.getSharedTripData.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(expressServer, nestServer, { path: '/api/shared/bad' });
- });
-});
diff --git a/server/tests/parity/l21-settings.parity.test.ts b/server/tests/parity/l21-settings.parity.test.ts
deleted file mode 100644
index 529f96e9..00000000
--- a/server/tests/parity/l21-settings.parity.test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * C6 parity — user settings.
- *
- * Same request at the legacy Express /api/settings route and the migrated Nest
- * controller, with settingsService and auth mocked identically. Pins routing,
- * the 400 guards, the masked-sentinel no-op and the bulk 200.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { settingsSvc } = vi.hoisted(() => ({
- settingsSvc: { getUserSettings: vi.fn(), upsertSetting: vi.fn(), bulkUpsertSettings: vi.fn() },
-}));
-vi.mock('../../src/services/settingsService', () => settingsSvc);
-
-import settingsRoutes from '../../src/routes/settings';
-import { SettingsModule } from '../../src/nest/settings/settings.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('C6 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/settings', settingsRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [SettingsModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- settingsSvc.getUserSettings.mockReturnValue({ theme: 'dark' });
- settingsSvc.bulkUpsertSettings.mockReturnValue(2);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /settings', () => expectParity(expressServer, nestServer, { path: '/api/settings' }));
- it('PUT /settings', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { key: 'theme', value: 'dark' } }));
- it('PUT /settings 400 no key', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { value: 'x' } }));
- it('PUT /settings masked sentinel no-op', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { key: 'k', value: '••••••••' } }));
- it('POST /settings/bulk (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/settings/bulk', body: { settings: { a: 1, b: 2 } } }));
- it('POST /settings/bulk 400 no object', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/settings/bulk', body: {} }));
-});
diff --git a/server/tests/parity/l22-backup.parity.test.ts b/server/tests/parity/l22-backup.parity.test.ts
deleted file mode 100644
index 3e25329e..00000000
--- a/server/tests/parity/l22-backup.parity.test.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * C7 parity — backup (admin-only).
- *
- * Same request at the legacy Express /api/backup route and the migrated Nest
- * controller, with backupService, auditLog and auth mocked identically (the
- * fixed user is an admin so both the legacy adminOnly and the Nest AdminGuard
- * pass). Multipart upload + res.download success differ per framework, so this
- * pins routing, the rate-limit 429, filename 400/404, restore status mapping
- * and the auto-settings/list/delete JSON.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedAdmin } = vi.hoisted(() => ({ fixedAdmin: { id: 1, username: 'a', email: 'a@example.test', role: 'admin' } }));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedAdmin;
- next();
- },
- adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedAdmin,
-}));
-
-vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
-
-const { backupSvc } = vi.hoisted(() => ({
- backupSvc: {
- listBackups: vi.fn(), createBackup: vi.fn(), restoreFromZip: vi.fn(), getAutoSettings: vi.fn(),
- updateAutoSettings: vi.fn(), deleteBackup: vi.fn(), isValidBackupFilename: vi.fn(), backupFilePath: vi.fn(),
- backupFileExists: vi.fn(), checkRateLimit: vi.fn(), getUploadTmpDir: () => '/tmp', BACKUP_RATE_WINDOW: 3600000,
- MAX_BACKUP_UPLOAD_SIZE: 1024,
- },
-}));
-vi.mock('../../src/services/backupService', () => backupSvc);
-
-import backupRoutes from '../../src/routes/backup';
-import { BackupModule } from '../../src/nest/backup/backup.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('C7 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/backup', backupRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [BackupModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- backupSvc.listBackups.mockReturnValue([{ filename: 'a.zip', size: 1 }]);
- backupSvc.createBackup.mockResolvedValue({ filename: 'b.zip', size: 10 });
- backupSvc.getAutoSettings.mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' });
- backupSvc.updateAutoSettings.mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 });
- backupSvc.restoreFromZip.mockResolvedValue({ success: true });
- });
-
- beforeEach(() => {
- backupSvc.isValidBackupFilename.mockReturnValue(true);
- backupSvc.backupFileExists.mockReturnValue(true);
- backupSvc.checkRateLimit.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /backup/list', () => expectParity(expressServer, nestServer, { path: '/api/backup/list' }));
- it('POST /backup/create', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/create' }));
- it('POST /backup/create 429 rate-limited', () => {
- backupSvc.checkRateLimit.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/create' });
- });
- it('GET /backup/download/:f 400 invalid', () => {
- backupSvc.isValidBackupFilename.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { path: '/api/backup/download/bad' });
- });
- it('GET /backup/download/:f 404 missing', () => {
- backupSvc.backupFileExists.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { path: '/api/backup/download/x.zip' });
- });
- it('POST /backup/restore/:f', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/x.zip' }));
- it('POST /backup/restore/:f 400 invalid', () => {
- backupSvc.isValidBackupFilename.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/bad' });
- });
- it('POST /backup/restore/:f maps the service status', () => {
- backupSvc.restoreFromZip.mockResolvedValueOnce({ success: false, status: 422, error: 'bad zip' }).mockResolvedValueOnce({ success: false, status: 422, error: 'bad zip' });
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/x.zip' });
- });
- it('GET /backup/auto-settings', () => expectParity(expressServer, nestServer, { path: '/api/backup/auto-settings' }));
- it('PUT /backup/auto-settings', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/backup/auto-settings', body: { enabled: true } }));
- it('DELETE /backup/:f', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/backup/x.zip' }));
- it('DELETE /backup/:f 404', () => {
- backupSvc.backupFileExists.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/backup/x.zip' });
- });
-});
diff --git a/server/tests/parity/l23-auth.parity.test.ts b/server/tests/parity/l23-auth.parity.test.ts
deleted file mode 100644
index 3a50bff5..00000000
--- a/server/tests/parity/l23-auth.parity.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-/**
- * A1 parity — auth (public flows + authenticated account/MFA/token endpoints).
- *
- * Same request at the legacy Express /api/auth route and the migrated Nest
- * controllers, with authService, the cookie service, notifications, auditLog and
- * auth middleware mocked identically. Cookies are a header side-effect (not
- * compared) and the rate-limit 429 + multipart avatar are covered in the unit
- * tests; this pins routing, status codes (register/mcp-token 201 vs the rest
- * 200), the login/reset MFA branches and the {error,status} envelopes.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
- optionalAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
- demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/services/cookie', () => ({ setAuthCookie: vi.fn(), clearAuthCookie: vi.fn() }));
-vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
-vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://x', sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: true }) }));
-
-const { authSvc } = vi.hoisted(() => ({
- authSvc: {
- getAppConfig: vi.fn(), demoLogin: vi.fn(), validateInviteToken: vi.fn(), registerUser: vi.fn(), loginUser: vi.fn(),
- requestPasswordReset: vi.fn(), resetPassword: vi.fn(), verifyMfaLogin: vi.fn(), getCurrentUser: vi.fn(),
- changePassword: vi.fn(), deleteAccount: vi.fn(), updateMapsKey: vi.fn(), updateApiKeys: vi.fn(), updateSettings: vi.fn(),
- getSettings: vi.fn(), saveAvatar: vi.fn(), deleteAvatar: vi.fn(), listUsers: vi.fn(), validateKeys: vi.fn(),
- getAppSettings: vi.fn(), updateAppSettings: vi.fn(), getTravelStats: vi.fn(), setupMfa: vi.fn(), enableMfa: vi.fn(),
- disableMfa: vi.fn(), listMcpTokens: vi.fn(), createMcpToken: vi.fn(), deleteMcpToken: vi.fn(), createWsToken: vi.fn(),
- createResourceToken: vi.fn(), requestPasswordReset_unused: vi.fn(),
- },
-}));
-vi.mock('../../src/services/authService', () => authSvc);
-
-import authRoutes from '../../src/routes/auth';
-import { AuthModule } from '../../src/nest/auth/auth.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('A1 parity (Express vs Nest)', () => {
- let ex: express.Express;
- let ne: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/auth', authRoutes);
- return app;
- }
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [AuthModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- ex = buildExpress();
- nestApp = await buildNest();
- ne = nestApp.getHttpServer();
- authSvc.getAppConfig.mockReturnValue({ version: '3', features: {} });
- authSvc.demoLogin.mockReturnValue({ token: 'tk', user: fixedUser });
- authSvc.validateInviteToken.mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null });
- authSvc.registerUser.mockReturnValue({ token: 'tk', user: fixedUser, auditUserId: 1, auditDetails: {} });
- authSvc.loginUser.mockReturnValue({ token: 'tk', user: fixedUser });
- authSvc.requestPasswordReset.mockReturnValue({ reason: 'no_user', userId: null });
- authSvc.resetPassword.mockReturnValue({ userId: 1 });
- authSvc.verifyMfaLogin.mockReturnValue({ token: 'tk', user: fixedUser, auditUserId: 1 });
- authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' });
- authSvc.changePassword.mockReturnValue({});
- authSvc.deleteAccount.mockReturnValue({});
- authSvc.updateMapsKey.mockReturnValue({ success: true });
- authSvc.updateApiKeys.mockReturnValue({ success: true });
- authSvc.updateSettings.mockReturnValue({ success: true, user: fixedUser });
- authSvc.getSettings.mockReturnValue({ settings: { theme: 'dark' } });
- authSvc.deleteAvatar.mockResolvedValue({ success: true });
- authSvc.listUsers.mockReturnValue([{ id: 1 }]);
- authSvc.validateKeys.mockResolvedValue({ maps: true, weather: true, maps_details: {} });
- authSvc.getAppSettings.mockReturnValue({ data: { foo: 'bar' } });
- authSvc.updateAppSettings.mockReturnValue({ auditSummary: {}, auditDebugDetails: {} });
- authSvc.getTravelStats.mockReturnValue({ trips: 5 });
- authSvc.enableMfa.mockReturnValue({ mfa_enabled: true, backup_codes: ['a'] });
- authSvc.disableMfa.mockReturnValue({ mfa_enabled: false });
- authSvc.listMcpTokens.mockReturnValue([{ id: 't1' }]);
- authSvc.createMcpToken.mockReturnValue({ token: 'mcp_x' });
- authSvc.deleteMcpToken.mockReturnValue({});
- authSvc.createWsToken.mockReturnValue({ token: 'ws_x' });
- authSvc.createResourceToken.mockReturnValue({ token: 'rt_x' });
- });
-
- afterAll(async () => { await nestApp.close(); });
-
- it('GET /app-config', () => expectParity(ex, ne, { path: '/api/auth/app-config' }));
- it('POST /demo-login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/demo-login' }));
- it('GET /invite/:token', () => expectParity(ex, ne, { path: '/api/auth/invite/tok' }));
- it('POST /register (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/register', body: { email: 'a@b.c', password: 'p' } }));
- it('POST /login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: { email: 'a@b.c', password: 'p' } }));
- it('POST /login mfa branch', () => {
- authSvc.loginUser.mockReturnValueOnce({ mfa_required: true, mfa_token: 'mt' }).mockReturnValueOnce({ mfa_required: true, mfa_token: 'mt' });
- return expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: {} });
- });
- it('POST /login 401', () => {
- authSvc.loginUser.mockReturnValueOnce({ error: 'Bad creds', status: 401 }).mockReturnValueOnce({ error: 'Bad creds', status: 401 });
- return expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: {} });
- });
- it('POST /forgot-password', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/forgot-password', body: { email: 'a@b.c' } }));
- it('POST /reset-password', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/reset-password', body: { token: 't', password: 'p' } }));
- it('POST /reset-password mfa branch', () => {
- authSvc.resetPassword.mockReturnValueOnce({ mfa_required: true }).mockReturnValueOnce({ mfa_required: true });
- return expectParity(ex, ne, { method: 'post', path: '/api/auth/reset-password', body: {} });
- });
- it('POST /logout', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/logout' }));
- it('POST /mfa/verify-login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/verify-login', body: { mfa_token: 't', code: '1' } }));
-
- it('GET /me', () => expectParity(ex, ne, { path: '/api/auth/me' }));
- it('GET /me 404', () => {
- authSvc.getCurrentUser.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
- return expectParity(ex, ne, { path: '/api/auth/me' });
- });
- it('PUT /me/password', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/password', body: { current_password: 'a', new_password: 'b' } }));
- it('DELETE /me', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/me' }));
- it('PUT /me/maps-key', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/maps-key', body: { maps_api_key: 'k' } }));
- it('PUT /me/api-keys', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/api-keys', body: {} }));
- it('PUT /me/settings', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/settings', body: {} }));
- it('GET /me/settings', () => expectParity(ex, ne, { path: '/api/auth/me/settings' }));
- it('DELETE /avatar', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/avatar' }));
- it('GET /users', () => expectParity(ex, ne, { path: '/api/auth/users' }));
- it('GET /validate-keys', () => expectParity(ex, ne, { path: '/api/auth/validate-keys' }));
- it('GET /app-settings', () => expectParity(ex, ne, { path: '/api/auth/app-settings' }));
- it('PUT /app-settings', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/app-settings', body: {} }));
- it('GET /travel-stats', () => expectParity(ex, ne, { path: '/api/auth/travel-stats' }));
- it('POST /mfa/enable', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/enable', body: { code: '1' } }));
- it('POST /mfa/disable', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/disable', body: {} }));
- it('GET /mcp-tokens', () => expectParity(ex, ne, { path: '/api/auth/mcp-tokens' }));
- it('POST /mcp-tokens (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mcp-tokens', body: { name: 'CLI' } }));
- it('DELETE /mcp-tokens/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/mcp-tokens/t1' }));
- it('POST /ws-token', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/ws-token' }));
- it('POST /resource-token', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/resource-token', body: { purpose: 'download' } }));
- it('POST /resource-token 503', () => {
- authSvc.createResourceToken.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(ex, ne, { method: 'post', path: '/api/auth/resource-token', body: {} });
- });
-});
diff --git a/server/tests/parity/l24-oidc.parity.test.ts b/server/tests/parity/l24-oidc.parity.test.ts
deleted file mode 100644
index cb6ce2a2..00000000
--- a/server/tests/parity/l24-oidc.parity.test.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * A2 parity — OIDC SSO.
- *
- * Same request at the legacy Express /api/auth/oidc route and the migrated Nest
- * controller, with oidcService, authService.resolveAuthToggles, the cookie
- * service and getAppUrl mocked identically. Redirects compare by status (302,
- * same Location by construction); the disabled/not-configured/exchange branches
- * compare the JSON bodies. supertest does not follow redirects, so 302 bodies
- * stay empty on both sides.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-vi.mock('../../src/services/cookie', () => ({ setAuthCookie: vi.fn() }));
-vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://app' }));
-
-const { toggles } = vi.hoisted(() => ({ toggles: { oidc_login: true } }));
-vi.mock('../../src/services/authService', () => ({ resolveAuthToggles: () => toggles }));
-
-const { oidcSvc } = vi.hoisted(() => ({
- oidcSvc: {
- getOidcConfig: vi.fn(), discover: vi.fn(), createState: vi.fn(), consumeState: vi.fn(), createAuthCode: vi.fn(),
- consumeAuthCode: vi.fn(), exchangeCodeForToken: vi.fn(), getUserInfo: vi.fn(), verifyIdToken: vi.fn(),
- findOrCreateUser: vi.fn(), touchLastLogin: vi.fn(), generateToken: vi.fn(), frontendUrl: (p: string) => 'https://app' + p,
- },
-}));
-vi.mock('../../src/services/oidcService', () => oidcSvc);
-
-import oidcRoutes from '../../src/routes/oidc';
-import { OidcModule } from '../../src/nest/oidc/oidc.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('A2 parity (Express vs Nest)', () => {
- let ex: express.Express;
- let ne: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/auth/oidc', oidcRoutes);
- return app;
- }
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [OidcModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- ex = buildExpress();
- nestApp = await buildNest();
- ne = nestApp.getHttpServer();
- oidcSvc.getOidcConfig.mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null });
- oidcSvc.discover.mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' });
- oidcSvc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' });
- oidcSvc.consumeState.mockReturnValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv' });
- oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt' });
- });
-
- beforeEach(() => { toggles.oidc_login = true; });
-
- afterAll(async () => { await nestApp.close(); });
-
- it('GET /login redirects (302)', () => expectParity(ex, ne, { path: '/api/auth/oidc/login' }));
- it('GET /login 403 when SSO disabled', () => {
- toggles.oidc_login = false;
- return expectParity(ex, ne, { path: '/api/auth/oidc/login' });
- });
- it('GET /login 400 not configured', () => {
- oidcSvc.getOidcConfig.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(ex, ne, { path: '/api/auth/oidc/login' });
- });
- it('GET /callback redirects on missing params', () => expectParity(ex, ne, { path: '/api/auth/oidc/callback' }));
- it('GET /callback redirects with provider error', () => expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { error: 'access_denied' } }));
- it('GET /callback redirects on invalid state', () => {
- oidcSvc.consumeState.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { code: 'c', state: 's' } });
- });
- it('GET /callback completes the full flow with an auth-code redirect', () => {
- // Drive the whole success chain so the service wrappers (exchange/verify/
- // userinfo/provision/token/auth-code) run on both stacks.
- oidcSvc.consumeState.mockReturnValueOnce({ redirectUri: 'https://app/cb', codeVerifier: 'cv' }).mockReturnValueOnce({ redirectUri: 'https://app/cb', codeVerifier: 'cv' });
- oidcSvc.exchangeCodeForToken.mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' });
- oidcSvc.verifyIdToken.mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
- oidcSvc.getUserInfo.mockResolvedValue({ email: 'a@b.c', sub: 'u1' });
- oidcSvc.findOrCreateUser.mockReturnValue({ user: { id: 1 } });
- oidcSvc.generateToken.mockReturnValue('jwt');
- oidcSvc.createAuthCode.mockReturnValue('ac');
- return expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { code: 'c', state: 's' } });
- });
-
- it('GET /exchange 400 without a code', () => expectParity(ex, ne, { path: '/api/auth/oidc/exchange' }));
- it('GET /exchange 400 on an invalid code', () => {
- oidcSvc.consumeAuthCode.mockReturnValueOnce({ error: 'invalid_code' }).mockReturnValueOnce({ error: 'invalid_code' });
- return expectParity(ex, ne, { path: '/api/auth/oidc/exchange', query: { code: 'bad' } });
- });
- it('GET /exchange sets cookie + returns token', () => expectParity(ex, ne, { path: '/api/auth/oidc/exchange', query: { code: 'good' } }));
-});
diff --git a/server/tests/parity/l25-oauth.parity.test.ts b/server/tests/parity/l25-oauth.parity.test.ts
deleted file mode 100644
index 3cacfe7d..00000000
--- a/server/tests/parity/l25-oauth.parity.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-/**
- * A3 parity — OAuth 2.1 server (public token/userinfo/revoke + the SPA's
- * /api/oauth management endpoints).
- *
- * Same request at the legacy Express routers and the migrated Nest controllers,
- * with oauthService, the MCP addon gate, getMcpSafeUrl, auditLog and auth
- * mocked identically. The Nest app gets cookie-parser and the cookie-auth
- * routes are sent a trek_session cookie (the legacy mocks ignore it). Pins the
- * grant branches, RFC error bodies, the empty-404 gate and the consent redirect.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import cookieParser from 'cookie-parser';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
-const COOKIE = { Cookie: 'trek_session=x' };
-
-vi.mock('../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {} }));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
- optionalAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
- requireCookieAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
-vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
-vi.mock('../../src/services/notifications', () => ({ getMcpSafeUrl: () => 'https://app' }));
-vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logWarn: vi.fn() }));
-
-const { oauthSvc } = vi.hoisted(() => ({
- oauthSvc: {
- validateAuthorizeRequest: vi.fn(), createAuthCode: vi.fn(), consumeAuthCode: vi.fn(), saveConsent: vi.fn(),
- issueTokens: vi.fn(), issueClientCredentialsToken: vi.fn(), refreshTokens: vi.fn(), revokeToken: vi.fn(),
- verifyPKCE: vi.fn(), authenticateClient: vi.fn(), listOAuthClients: vi.fn(), createOAuthClient: vi.fn(),
- deleteOAuthClient: vi.fn(), rotateOAuthClientSecret: vi.fn(), listOAuthSessions: vi.fn(), revokeSession: vi.fn(),
- getUserByAccessToken: vi.fn(),
- },
-}));
-vi.mock('../../src/services/oauthService', () => oauthSvc);
-
-import { oauthPublicRouter, oauthApiRouter } from '../../src/routes/oauth';
-import { OauthModule } from '../../src/nest/oauth/oauth.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('A3 parity (Express vs Nest)', () => {
- let ex: express.Express;
- let ne: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use(cookieParser());
- app.use('/api/oauth', oauthApiRouter);
- app.use('/', oauthPublicRouter);
- return app;
- }
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [OauthModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.use(cookieParser());
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- ex = buildExpress();
- nestApp = await buildNest();
- ne = nestApp.getHttpServer();
- oauthSvc.getUserByAccessToken.mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } });
- oauthSvc.authenticateClient.mockReturnValue({ id: 'c', is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' });
- oauthSvc.listOAuthClients.mockReturnValue([{ id: 'c1' }]);
- oauthSvc.listOAuthSessions.mockReturnValue([{ id: 1 }]);
- oauthSvc.createOAuthClient.mockReturnValue({ client_id: 'c1', client_secret: 's' });
- oauthSvc.deleteOAuthClient.mockReturnValue({});
- oauthSvc.revokeSession.mockReturnValue({});
- oauthSvc.validateAuthorizeRequest.mockReturnValue({ valid: true, scopes: ['s'], resource: null, client_name: 'CLI', allowed_scopes: ['s'] });
- oauthSvc.createAuthCode.mockReturnValue('the_code');
- });
-
- beforeEach(() => { isAddonEnabled.mockReturnValue(true); });
-
- afterAll(async () => { await nestApp.close(); });
-
- // Public — token
- it('POST /oauth/token 401 without client_id', () => expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: {} }));
- it('POST /oauth/token unsupported grant', () => expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { client_id: 'c', grant_type: 'password' } }));
- it('POST /oauth/token authorization_code invalid_grant', () => {
- oauthSvc.consumeAuthCode.mockReturnValueOnce(null).mockReturnValueOnce(null);
- return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' } });
- });
- it('POST /oauth/token authorization_code success', () => {
- oauthSvc.consumeAuthCode.mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null });
- oauthSvc.verifyPKCE.mockReturnValue(true);
- oauthSvc.issueTokens.mockReturnValue({ access_token: 'at', token_type: 'Bearer', expires_in: 3600 });
- return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' } });
- });
- it('POST /oauth/token client_credentials success', () => {
- oauthSvc.issueClientCredentialsToken.mockReturnValue({ access_token: 'cc_at', token_type: 'Bearer' });
- return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'client_credentials', client_id: 'c', client_secret: 's' } });
- });
- it('POST /oauth/token 404 when MCP disabled', () => {
- isAddonEnabled.mockReturnValue(false);
- return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { client_id: 'c' } });
- });
-
- // Public — userinfo + revoke
- it('GET /oauth/userinfo 401 without Bearer', () => expectParity(ex, ne, { path: '/oauth/userinfo' }));
- it('GET /oauth/userinfo with Bearer', () => expectParity(ex, ne, { path: '/oauth/userinfo', headers: { Authorization: 'Bearer tok' } }));
- it('POST /oauth/revoke 400 without token', () => expectParity(ex, ne, { method: 'post', path: '/oauth/revoke', body: { client_id: 'c' } }));
- it('POST /oauth/revoke 200', () => expectParity(ex, ne, { method: 'post', path: '/oauth/revoke', body: { token: 't', client_id: 'c' } }));
-
- // API — validate / authorize / clients / sessions
- it('GET /api/oauth/authorize/validate', () => expectParity(ex, ne, { path: '/api/oauth/authorize/validate', query: { response_type: 'code', client_id: 'c', redirect_uri: 'u', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256' }, headers: COOKIE }));
- it('GET /api/oauth/authorize/validate 404 MCP off', () => {
- isAddonEnabled.mockReturnValue(false);
- return expectParity(ex, ne, { path: '/api/oauth/authorize/validate', headers: COOKIE });
- });
- it('POST /api/oauth/authorize denied redirect', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/authorize', headers: COOKIE, body: { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false } }));
- it('POST /api/oauth/authorize approved redirect', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/authorize', headers: COOKIE, body: { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true } }));
- it('GET /api/oauth/clients', () => expectParity(ex, ne, { path: '/api/oauth/clients', headers: COOKIE }));
- it('POST /api/oauth/clients (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/clients', headers: COOKIE, body: { name: 'CLI', allowed_scopes: ['a'] } }));
- it('DELETE /api/oauth/clients/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/oauth/clients/c1', headers: COOKIE }));
- it('GET /api/oauth/sessions', () => expectParity(ex, ne, { path: '/api/oauth/sessions', headers: COOKIE }));
- it('DELETE /api/oauth/sessions/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/oauth/sessions/1', headers: COOKIE }));
- it('GET /api/oauth/clients 403 MCP off', () => {
- isAddonEnabled.mockReturnValue(false);
- return expectParity(ex, ne, { path: '/api/oauth/clients', headers: COOKIE });
- });
-});
diff --git a/server/tests/parity/l26-admin.parity.test.ts b/server/tests/parity/l26-admin.parity.test.ts
deleted file mode 100644
index 1d65e688..00000000
--- a/server/tests/parity/l26-admin.parity.test.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-/**
- * A4 parity — admin control surface.
- *
- * Same request at the legacy Express /api/admin route and the migrated Nest
- * controller, with adminService, the settings/MCP/notification-pref helpers,
- * auditLog and auth mocked identically (the fixed user is an admin so both the
- * legacy adminOnly and the Nest AdminGuard pass). Pins routing, the create-201
- * vs 200 split, the {error,status} envelopes and the validation 400s across a
- * representative slice of each sub-domain.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedAdmin } = vi.hoisted(() => ({ fixedAdmin: { id: 1, username: 'a', email: 'a@example.test', role: 'admin' } }));
-
-vi.mock('../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {} }));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedAdmin; next(); },
- adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedAdmin,
-}));
-
-vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logInfo: vi.fn() }));
-vi.mock('../../src/mcp', () => ({ invalidateMcpSessions: vi.fn() }));
-vi.mock('../../src/services/notificationPreferencesService', () => ({ getPreferencesMatrix: vi.fn(() => ({ matrix: {} })), setAdminPreferences: vi.fn() }));
-vi.mock('../../src/services/settingsService', () => ({ getAdminUserDefaults: vi.fn(() => ({ theme: 'dark' })), setAdminUserDefaults: vi.fn() }));
-
-const { adminSvc } = vi.hoisted(() => ({
- adminSvc: {
- listUsers: vi.fn(), createUser: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), getStats: vi.fn(),
- getPermissions: vi.fn(), savePermissions: vi.fn(), getAuditLog: vi.fn(), getOidcSettings: vi.fn(), updateOidcSettings: vi.fn(),
- saveDemoBaseline: vi.fn(), getGithubReleases: vi.fn(), checkVersion: vi.fn(), listInvites: vi.fn(), createInvite: vi.fn(),
- deleteInvite: vi.fn(), getBagTracking: vi.fn(), updateBagTracking: vi.fn(), getPlacesPhotos: vi.fn(), updatePlacesPhotos: vi.fn(),
- getPlacesAutocomplete: vi.fn(), updatePlacesAutocomplete: vi.fn(), getPlacesDetails: vi.fn(), updatePlacesDetails: vi.fn(),
- getCollabFeatures: vi.fn(), updateCollabFeatures: vi.fn(), listPackingTemplates: vi.fn(), getPackingTemplate: vi.fn(),
- createPackingTemplate: vi.fn(), updatePackingTemplate: vi.fn(), deletePackingTemplate: vi.fn(), createTemplateCategory: vi.fn(),
- updateTemplateCategory: vi.fn(), deleteTemplateCategory: vi.fn(), createTemplateItem: vi.fn(), updateTemplateItem: vi.fn(),
- deleteTemplateItem: vi.fn(), listAddons: vi.fn(), updateAddon: vi.fn(), listMcpTokens: vi.fn(), deleteMcpToken: vi.fn(),
- listOAuthSessions: vi.fn(), revokeOAuthSession: vi.fn(), rotateJwtSecret: vi.fn(),
- },
-}));
-vi.mock('../../src/services/adminService', () => adminSvc);
-
-import adminRoutes from '../../src/routes/admin';
-import { AdminModule } from '../../src/nest/admin/admin.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('A4 parity (Express vs Nest)', () => {
- let ex: express.Express;
- let ne: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/admin', adminRoutes);
- return app;
- }
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [AdminModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- ex = buildExpress();
- nestApp = await buildNest();
- ne = nestApp.getHttpServer();
- adminSvc.listUsers.mockReturnValue([{ id: 1 }]);
- adminSvc.createUser.mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} });
- adminSvc.updateUser.mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] });
- adminSvc.deleteUser.mockReturnValue({ email: 'a@b.c' });
- adminSvc.getStats.mockReturnValue({ users: 3 });
- adminSvc.getPermissions.mockReturnValue({ permissions: {} });
- adminSvc.savePermissions.mockReturnValue({ permissions: { x: 1 }, skipped: [] });
- adminSvc.getAuditLog.mockReturnValue({ entries: [] });
- adminSvc.getOidcSettings.mockReturnValue({ issuer: '' });
- adminSvc.updateOidcSettings.mockReturnValue({});
- adminSvc.getGithubReleases.mockResolvedValue({ releases: [] });
- adminSvc.checkVersion.mockResolvedValue({ current: '3', latest: '3' });
- adminSvc.listInvites.mockReturnValue([]);
- adminSvc.createInvite.mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 });
- adminSvc.deleteInvite.mockReturnValue({});
- adminSvc.getBagTracking.mockReturnValue({ enabled: false });
- adminSvc.updateBagTracking.mockReturnValue({ enabled: true });
- adminSvc.updatePlacesPhotos.mockReturnValue({ enabled: true });
- adminSvc.getPlacesPhotos.mockReturnValue({ enabled: false });
- adminSvc.getPlacesAutocomplete.mockReturnValue({ enabled: false });
- adminSvc.updatePlacesAutocomplete.mockReturnValue({ enabled: true });
- adminSvc.getPlacesDetails.mockReturnValue({ enabled: false });
- adminSvc.updatePlacesDetails.mockReturnValue({ enabled: true });
- adminSvc.updatePackingTemplate.mockReturnValue({ id: 3, name: 'B2' });
- adminSvc.createTemplateCategory.mockReturnValue({ id: 4 });
- adminSvc.updateTemplateCategory.mockReturnValue({ id: 4 });
- adminSvc.deleteTemplateCategory.mockReturnValue({});
- adminSvc.updateTemplateItem.mockReturnValue({ id: 7 });
- adminSvc.deleteTemplateItem.mockReturnValue({});
- adminSvc.getCollabFeatures.mockReturnValue({ chat: true });
- adminSvc.updateCollabFeatures.mockReturnValue({ chat: false });
- adminSvc.listPackingTemplates.mockReturnValue([]);
- adminSvc.getPackingTemplate.mockReturnValue({ id: 3 });
- adminSvc.createPackingTemplate.mockReturnValue({ id: 3, name: 'Beach' });
- adminSvc.deletePackingTemplate.mockReturnValue({ name: 'Beach' });
- adminSvc.createTemplateItem.mockReturnValue({ id: 7 });
- adminSvc.listAddons.mockReturnValue([{ id: 'mcp' }]);
- adminSvc.updateAddon.mockReturnValue({ addon: { id: 'mcp', enabled: true }, auditDetails: {} });
- adminSvc.listMcpTokens.mockReturnValue([]);
- adminSvc.deleteMcpToken.mockReturnValue({});
- adminSvc.listOAuthSessions.mockReturnValue([]);
- adminSvc.revokeOAuthSession.mockReturnValue({});
- adminSvc.rotateJwtSecret.mockReturnValue({});
- });
-
- beforeEach(() => { delete process.env.NODE_ENV; });
-
- afterAll(async () => { await nestApp.close(); });
-
- it('GET /users', () => expectParity(ex, ne, { path: '/api/admin/users' }));
- it('POST /users (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/users', body: { email: 'a@b.c' } }));
- it('POST /users error', () => {
- adminSvc.createUser.mockReturnValueOnce({ error: 'taken', status: 409 }).mockReturnValueOnce({ error: 'taken', status: 409 });
- return expectParity(ex, ne, { method: 'post', path: '/api/admin/users', body: {} });
- });
- it('PUT /users/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/users/2', body: { role: 'admin' } }));
- it('DELETE /users/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/users/2' }));
- it('GET /stats', () => expectParity(ex, ne, { path: '/api/admin/stats' }));
- it('GET /permissions', () => expectParity(ex, ne, { path: '/api/admin/permissions' }));
- it('PUT /permissions 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/permissions', body: {} }));
- it('PUT /permissions', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/permissions', body: { permissions: { x: 1 } } }));
- it('GET /audit-log', () => expectParity(ex, ne, { path: '/api/admin/audit-log', query: { limit: '10' } }));
- it('GET /oidc', () => expectParity(ex, ne, { path: '/api/admin/oidc' }));
- it('PUT /oidc', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/oidc', body: { issuer: 'https://idp' } }));
- it('POST /save-demo-baseline error', () => {
- adminSvc.saveDemoBaseline.mockReturnValueOnce({ error: 'not demo', status: 400 }).mockReturnValueOnce({ error: 'not demo', status: 400 });
- return expectParity(ex, ne, { method: 'post', path: '/api/admin/save-demo-baseline' });
- });
- it('GET /github-releases', () => expectParity(ex, ne, { path: '/api/admin/github-releases' }));
- it('GET /version-check', () => expectParity(ex, ne, { path: '/api/admin/version-check' }));
- it('GET /notification-preferences', () => expectParity(ex, ne, { path: '/api/admin/notification-preferences' }));
- it('GET /invites', () => expectParity(ex, ne, { path: '/api/admin/invites' }));
- it('POST /invites (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/invites', body: { max_uses: 1 } }));
- it('DELETE /invites/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/invites/5' }));
- it('PUT /bag-tracking', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/bag-tracking', body: { enabled: true } }));
- it('PUT /places-photos 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-photos', body: { enabled: 'yes' } }));
- it('PUT /places-photos', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-photos', body: { enabled: true } }));
- it('PUT /collab-features', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/collab-features', body: { chat: false } }));
- it('GET /places-photos', () => expectParity(ex, ne, { path: '/api/admin/places-photos' }));
- it('GET /places-autocomplete', () => expectParity(ex, ne, { path: '/api/admin/places-autocomplete' }));
- it('PUT /places-autocomplete', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-autocomplete', body: { enabled: true } }));
- it('GET /places-details', () => expectParity(ex, ne, { path: '/api/admin/places-details' }));
- it('PUT /places-details', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-details', body: { enabled: true } }));
- it('PUT /packing-templates/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3', body: { name: 'B2' } }));
- it('POST /packing-templates/:id/categories (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates/3/categories', body: { name: 'Cat' } }));
- it('PUT /packing-templates/:t/categories/:c', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3/categories/4', body: { name: 'C2' } }));
- it('DELETE /packing-templates/:t/categories/:c', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3/categories/4' }));
- it('PUT /packing-templates/:t/items/:i', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3/items/7', body: { name: 'I2' } }));
- it('DELETE /packing-templates/:t/items/:i', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3/items/7' }));
- it('DELETE /mcp-tokens/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/mcp-tokens/t1' }));
- it('PUT /notification-preferences', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/notification-preferences', body: {} }));
- it('GET /packing-templates', () => expectParity(ex, ne, { path: '/api/admin/packing-templates' }));
- it('GET /packing-templates/:id', () => expectParity(ex, ne, { path: '/api/admin/packing-templates/3' }));
- it('POST /packing-templates (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates', body: { name: 'Beach' } }));
- it('DELETE /packing-templates/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3' }));
- it('POST /packing-templates/:t/categories/:c/items (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates/3/categories/4/items', body: { name: 'Towel' } }));
- it('GET /addons', () => expectParity(ex, ne, { path: '/api/admin/addons' }));
- it('PUT /addons/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/addons/mcp', body: { enabled: true } }));
- it('GET /mcp-tokens', () => expectParity(ex, ne, { path: '/api/admin/mcp-tokens' }));
- it('GET /oauth-sessions', () => expectParity(ex, ne, { path: '/api/admin/oauth-sessions' }));
- it('DELETE /oauth-sessions/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/oauth-sessions/3' }));
- it('POST /rotate-jwt-secret', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/rotate-jwt-secret' }));
- it('GET /default-user-settings', () => expectParity(ex, ne, { path: '/api/admin/default-user-settings' }));
- it('PUT /default-user-settings 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/default-user-settings', body: [] }));
- it('PUT /default-user-settings', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/default-user-settings', body: { theme: 'dark' } }));
-});
diff --git a/server/tests/parity/l3.parity.test.ts b/server/tests/parity/l3.parity.test.ts
deleted file mode 100644
index 37d98b88..00000000
--- a/server/tests/parity/l3.parity.test.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-/**
- * L3 parity — maps / geo.
- *
- * Fires the same request at the legacy Express /api/maps route and the migrated
- * Nest controller with mapsService mocked identically for both, asserting
- * client-identical status + body. Covers the JSON endpoints; the file-serving
- * /place-photo/:placeId/bytes route is covered by the controller unit test.
- *
- * The per-endpoint kill-switches read app_settings; the stubbed DB returns no
- * rows, so every switch reads as "enabled" — the disabled short-circuits are
- * covered by the unit + e2e tests. Auth is neutralised identically for both apps.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'parity', email: 'parity@example.test', role: 'user' },
-}));
-
-// Stub DB: every app_settings lookup misses -> kill-switches read as enabled.
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'parity-token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { mocks } = vi.hoisted(() => ({
- mocks: {
- searchPlaces: vi.fn(),
- autocompletePlaces: vi.fn(),
- getPlaceDetails: vi.fn(),
- getPlaceDetailsExpanded: vi.fn(),
- getPlacePhoto: vi.fn(),
- reverseGeocode: vi.fn(),
- resolveGoogleMapsUrl: vi.fn(),
- },
-}));
-vi.mock('../../src/services/mapsService', async (importActual) => {
- const actual = await importActual();
- return { ...actual, ...mocks };
-});
-
-import mapsRoutes from '../../src/routes/maps';
-import { MapsModule } from '../../src/nest/maps/maps.module';
-import { DatabaseModule } from '../../src/nest/database/database.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('L3 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/maps', mapsRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, MapsModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- mocks.searchPlaces.mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' });
- mocks.autocompletePlaces.mockResolvedValue({ suggestions: [{ placeId: 'p', mainText: 'Berlin', secondaryText: 'DE' }], source: 'osm' });
- mocks.getPlaceDetails.mockResolvedValue({ place: { id: 'p1', name: 'Spot' } });
- mocks.getPlaceDetailsExpanded.mockResolvedValue({ place: { id: 'p1', name: 'Spot', expanded: true } });
- mocks.getPlacePhoto.mockResolvedValue({ photoUrl: 'http://x/y.jpg', attribution: 'CC' });
- mocks.reverseGeocode.mockResolvedValue({ name: 'Spot', address: 'Street 1' });
- mocks.resolveGoogleMapsUrl.mockResolvedValue({ lat: 52.5, lng: 13.4, name: null, address: null });
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('POST /search success', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: { query: 'berlin' } }));
-
- it('POST /search missing query (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: {} }));
-
- it('POST /search service error', async () => {
- mocks.searchPlaces.mockRejectedValueOnce(Object.assign(new Error('Rate limited'), { status: 429 }));
- mocks.searchPlaces.mockRejectedValueOnce(Object.assign(new Error('Rate limited'), { status: 429 }));
- await expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: { query: 'x' } });
- });
-
- it('POST /autocomplete success', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: { input: 'ber' } }));
-
- it('POST /autocomplete missing input (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: {} }));
-
- it('POST /autocomplete too long (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: { input: 'x'.repeat(201) } }));
-
- it('POST /autocomplete invalid locationBias (400)', () =>
- expectParity(expressServer, nestServer, {
- method: 'post', path: '/api/maps/autocomplete',
- body: { input: 'ber', locationBias: { low: { lat: 1, lng: 'no' }, high: { lat: 2, lng: 3 } } },
- }));
-
- it('GET /details/:placeId', () =>
- expectParity(expressServer, nestServer, { path: '/api/maps/details/p1' }));
-
- it('GET /details/:placeId?expand=full', () =>
- expectParity(expressServer, nestServer, { path: '/api/maps/details/p1', query: { expand: 'full' } }));
-
- it('GET /place-photo/:placeId', () =>
- expectParity(expressServer, nestServer, { path: '/api/maps/place-photo/p1', query: { lat: '1', lng: '2' } }));
-
- it('GET /reverse success', () =>
- expectParity(expressServer, nestServer, { path: '/api/maps/reverse', query: { lat: '52.5', lng: '13.4' } }));
-
- it('GET /reverse missing lat/lng (400)', () =>
- expectParity(expressServer, nestServer, { path: '/api/maps/reverse', query: { lat: '52.5' } }));
-
- it('POST /resolve-url success', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/resolve-url', body: { url: 'https://maps.app.goo.gl/x' } }));
-
- it('POST /resolve-url missing url (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/resolve-url', body: {} }));
-});
diff --git a/server/tests/parity/l4.parity.test.ts b/server/tests/parity/l4.parity.test.ts
deleted file mode 100644
index 77b808a9..00000000
--- a/server/tests/parity/l4.parity.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * L4 parity — categories CRUD.
- *
- * Fires the same request at the legacy Express /api/categories route and the
- * migrated Nest controller with categoryService mocked identically for both,
- * asserting client-identical status + body. Auth + admin are neutralised the
- * same way for both apps (a fixed admin user); the 401/403 paths are covered by
- * the e2e test against the real guards.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { adminUser } = vi.hoisted(() => ({
- adminUser: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin' },
-}));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = adminUser;
- next();
- },
- adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => adminUser,
-}));
-
-const { mocks } = vi.hoisted(() => ({
- mocks: {
- listCategories: vi.fn(),
- createCategory: vi.fn(),
- getCategoryById: vi.fn(),
- updateCategory: vi.fn(),
- deleteCategory: vi.fn(),
- },
-}));
-vi.mock('../../src/services/categoryService', () => mocks);
-
-import categoriesRoutes from '../../src/routes/categories';
-import { CategoriesModule } from '../../src/nest/categories/categories.module';
-import { DatabaseModule } from '../../src/nest/database/database.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-const cat = { id: 1, name: 'Food', color: '#fff', icon: '🍔' };
-
-describe('L4 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/categories', categoriesRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CategoriesModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- mocks.listCategories.mockReturnValue([cat]);
- mocks.createCategory.mockReturnValue(cat);
- mocks.updateCategory.mockReturnValue({ ...cat, name: 'Drinks' });
- mocks.getCategoryById.mockImplementation((id: string | number) => (String(id) === '1' ? cat : undefined));
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/categories' }));
-
- it('POST / create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/categories', body: { name: 'Food', color: '#fff', icon: '🍔' } }));
-
- it('POST / missing name (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/categories', body: {} }));
-
- it('PUT /:id found (200)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/categories/1', body: { name: 'Drinks' } }));
-
- it('PUT /:id not found (404)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/categories/9', body: { name: 'X' } }));
-
- it('DELETE /:id found (200)', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/categories/1' }));
-
- it('DELETE /:id not found (404)', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/categories/9' }));
-});
diff --git a/server/tests/parity/l5.parity.test.ts b/server/tests/parity/l5.parity.test.ts
deleted file mode 100644
index 499c70ef..00000000
--- a/server/tests/parity/l5.parity.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * L5 parity — tags CRUD.
- *
- * Fires the same request at the legacy Express /api/tags route and the migrated
- * Nest controller with tagService mocked identically for both, asserting
- * client-identical status + body. Auth is neutralised identically (a fixed user);
- * the 401 path is covered by the e2e test against the real guard.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({
- fixedUser: { id: 5, username: 'u', email: 'u@example.test', role: 'user' },
-}));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { mocks } = vi.hoisted(() => ({
- mocks: {
- listTags: vi.fn(),
- createTag: vi.fn(),
- getTagByIdAndUser: vi.fn(),
- updateTag: vi.fn(),
- deleteTag: vi.fn(),
- },
-}));
-vi.mock('../../src/services/tagService', () => mocks);
-
-import tagsRoutes from '../../src/routes/tags';
-import { TagsModule } from '../../src/nest/tags/tags.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-const tag = { id: 1, user_id: 5, name: 'Beach', color: '#10b981' };
-
-describe('L5 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/tags', tagsRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [TagsModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- mocks.listTags.mockReturnValue([tag]);
- mocks.createTag.mockReturnValue(tag);
- mocks.updateTag.mockReturnValue({ ...tag, name: 'Hike' });
- mocks.getTagByIdAndUser.mockImplementation((id: string | number) => (String(id) === '1' ? tag : undefined));
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/tags' }));
-
- it('POST / create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/tags', body: { name: 'Beach', color: '#10b981' } }));
-
- it('POST / missing name (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/tags', body: {} }));
-
- it('PUT /:id found (200)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/tags/1', body: { name: 'Hike' } }));
-
- it('PUT /:id not found (404)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/tags/9', body: { name: 'X' } }));
-
- it('DELETE /:id found (200)', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/tags/1' }));
-
- it('DELETE /:id not found (404)', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/tags/9' }));
-});
diff --git a/server/tests/parity/l6.parity.test.ts b/server/tests/parity/l6.parity.test.ts
deleted file mode 100644
index eb23224a..00000000
--- a/server/tests/parity/l6.parity.test.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-/**
- * L6 parity — notifications.
- *
- * Fires the same request at the legacy Express /api/notifications route and the
- * migrated Nest controller with the three notification services mocked
- * identically for both, asserting client-identical status + body. Includes the
- * route-ordering trap (DELETE /in-app/all must NOT be captured by /in-app/:id).
- * Auth/admin are neutralised the same way (a fixed admin user); the 401/403
- * paths are covered by the e2e test against the real guard.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { adminUser } = vi.hoisted(() => ({
- adminUser: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin' },
-}));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = adminUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => adminUser,
-}));
-
-const { prefs, inapp, channels } = vi.hoisted(() => ({
- prefs: { getPreferencesMatrix: vi.fn(), setPreferences: vi.fn() },
- inapp: {
- getNotifications: vi.fn(), getUnreadCount: vi.fn(), markRead: vi.fn(), markUnread: vi.fn(),
- markAllRead: vi.fn(), deleteNotification: vi.fn(), deleteAll: vi.fn(), respondToBoolean: vi.fn(),
- },
- channels: {
- testSmtp: vi.fn(), testWebhook: vi.fn(), testNtfy: vi.fn(),
- getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn(),
- getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn(),
- },
-}));
-vi.mock('../../src/services/notificationPreferencesService', () => prefs);
-vi.mock('../../src/services/inAppNotifications', () => inapp);
-vi.mock('../../src/services/notifications', () => channels);
-
-import notificationsRoutes from '../../src/routes/notifications';
-import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('L6 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/notifications', notificationsRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [NotificationsModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} });
- inapp.getNotifications.mockReturnValue({ notifications: [{ id: 1 }], total: 1, unread_count: 1 });
- inapp.getUnreadCount.mockReturnValue(2);
- inapp.markAllRead.mockReturnValue(3);
- inapp.deleteAll.mockReturnValue(4);
- inapp.markRead.mockImplementation((id: number) => id === 5);
- inapp.deleteNotification.mockImplementation((id: number) => id === 5);
- inapp.respondToBoolean.mockResolvedValue({ success: true, notification: { id: 5, response: 'positive' } });
- channels.testSmtp.mockResolvedValue({ success: true });
- channels.testWebhook.mockResolvedValue({ success: true });
- channels.getAdminNtfyConfig.mockReturnValue({ server: null, token: null });
- channels.getUserNtfyConfig.mockReturnValue(null);
- channels.testNtfy.mockResolvedValue({ success: true });
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /preferences', () => expectParity(expressServer, nestServer, { path: '/api/notifications/preferences' }));
-
- it('PUT /preferences', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/preferences', body: { trip_invite: { inapp: true } } }));
-
- it('POST /test-smtp (admin, 200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-smtp', body: { email: 'x@y.z' } }));
-
- it('POST /test-webhook with a url (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-webhook', body: { url: 'https://hooks.example/x' } }));
-
- it('POST /test-webhook invalid url (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-webhook', body: { url: 'not a url' } }));
-
- it('POST /test-ntfy with a topic (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-ntfy', body: { topic: 'mytopic' } }));
-
- it('POST /test-ntfy no topic (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-ntfy', body: {} }));
-
- it('GET /in-app', () =>
- expectParity(expressServer, nestServer, { path: '/api/notifications/in-app', query: { limit: '10', offset: '0' } }));
-
- it('GET /in-app/unread-count', () =>
- expectParity(expressServer, nestServer, { path: '/api/notifications/in-app/unread-count' }));
-
- it('PUT /in-app/read-all', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/read-all' }));
-
- it('DELETE /in-app/all (must not be captured by /:id)', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/notifications/in-app/all' }));
-
- it('PUT /in-app/:id/read success', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/5/read' }));
-
- it('PUT /in-app/:id/read 404', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/9/read' }));
-
- it('PUT /in-app/:id/read invalid id (400)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/abc/read' }));
-
- it('DELETE /in-app/:id success', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/notifications/in-app/5' }));
-
- it('POST /in-app/:id/respond success (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/in-app/5/respond', body: { response: 'positive' } }));
-
- it('POST /in-app/:id/respond invalid value (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/in-app/5/respond', body: { response: 'maybe' } }));
-});
diff --git a/server/tests/parity/l7.parity.test.ts b/server/tests/parity/l7.parity.test.ts
deleted file mode 100644
index 2e69ad25..00000000
--- a/server/tests/parity/l7.parity.test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * L7 parity — atlas addon.
- *
- * Fires the same request at the legacy Express /api/addons/atlas route and the
- * migrated Nest controller with atlasService mocked identically for both,
- * asserting client-identical status + body. (Cache-Control headers are asserted
- * in the controller unit test; expectParity compares status + body.) Auth is
- * neutralised identically; the 401 path is covered by the e2e test.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
-}));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { mocks } = vi.hoisted(() => ({
- mocks: {
- getStats: vi.fn(),
- getCountryPlaces: vi.fn(),
- markCountryVisited: vi.fn(),
- unmarkCountryVisited: vi.fn(),
- markRegionVisited: vi.fn(),
- unmarkRegionVisited: vi.fn(),
- getVisitedRegions: vi.fn(),
- getRegionGeo: vi.fn(),
- listBucketList: vi.fn(),
- createBucketItem: vi.fn(),
- updateBucketItem: vi.fn(),
- deleteBucketItem: vi.fn(),
- },
-}));
-vi.mock('../../src/services/atlasService', () => mocks);
-
-import atlasRoutes from '../../src/routes/atlas';
-import { AtlasModule } from '../../src/nest/atlas/atlas.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('L7 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/addons/atlas', atlasRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [AtlasModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- mocks.getStats.mockResolvedValue({ countries: 3, cities: 10, continents: 2 });
- mocks.getVisitedRegions.mockResolvedValue({ regions: {} });
- mocks.getRegionGeo.mockResolvedValue({ type: 'FeatureCollection', features: [{ id: 1 }] });
- mocks.getCountryPlaces.mockReturnValue({ places: [] });
- mocks.listBucketList.mockReturnValue([{ id: 1, name: 'Tokyo' }]);
- mocks.createBucketItem.mockReturnValue({ id: 2, name: 'Kyoto' });
- mocks.updateBucketItem.mockImplementation((_u: number, id: string | number) => (String(id) === '1' ? { id: 1, name: 'Edited' } : null));
- mocks.deleteBucketItem.mockImplementation((_u: number, id: string | number) => String(id) === '1');
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /stats', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/stats' }));
- it('GET /regions', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions' }));
- it('GET /regions/geo empty', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions/geo' }));
- it('GET /regions/geo non-empty', () =>
- expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions/geo', query: { countries: 'DE,FR' } }));
- it('GET /country/:code', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/country/de' }));
- it('POST /country/:code/mark (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/country/de/mark' }));
- it('DELETE /country/:code/mark', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/country/de/mark' }));
- it('POST /region/:code/mark (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/region/by/mark', body: { name: 'Bavaria', country_code: 'de' } }));
- it('POST /region/:code/mark missing fields (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/region/by/mark', body: { name: 'Bavaria' } }));
- it('DELETE /region/:code/mark', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/region/by/mark' }));
- it('GET /bucket-list', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/bucket-list' }));
- it('POST /bucket-list create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/bucket-list', body: { name: 'Kyoto' } }));
- it('POST /bucket-list blank name (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/bucket-list', body: { name: ' ' } }));
- it('PUT /bucket-list/:id found (200)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/atlas/bucket-list/1', body: { name: 'Edited' } }));
- it('PUT /bucket-list/:id not found (404)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/atlas/bucket-list/9', body: { name: 'X' } }));
- it('DELETE /bucket-list/:id found (200)', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/bucket-list/1' }));
- it('DELETE /bucket-list/:id not found (404)', () =>
- expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/bucket-list/9' }));
-});
diff --git a/server/tests/parity/l8-vacay.parity.test.ts b/server/tests/parity/l8-vacay.parity.test.ts
deleted file mode 100644
index fa180f9e..00000000
--- a/server/tests/parity/l8-vacay.parity.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * S1 parity — vacay addon.
- *
- * Fires the same request at the legacy Express /api/addons/vacay route and the
- * migrated Nest controller with vacayService mocked identically for both,
- * asserting client-identical status + body. Auth is neutralised identically; the
- * 401 path is covered by the e2e test. Covers the validation/403/error-status
- * paths and the POST-stays-200 behaviour.
- */
-import { describe, it, beforeAll, afterAll, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
-}));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-const { svc } = vi.hoisted(() => ({
- svc: {
- getPlanData: vi.fn(), getActivePlanId: vi.fn(), getActivePlan: vi.fn(), updatePlan: vi.fn(),
- addHolidayCalendar: vi.fn(), updateHolidayCalendar: vi.fn(), deleteHolidayCalendar: vi.fn(),
- getPlanUsers: vi.fn(), setUserColor: vi.fn(), sendInvite: vi.fn(), acceptInvite: vi.fn(),
- declineInvite: vi.fn(), cancelInvite: vi.fn(), dissolvePlan: vi.fn(), getAvailableUsers: vi.fn(),
- listYears: vi.fn(), addYear: vi.fn(), deleteYear: vi.fn(), getEntries: vi.fn(),
- toggleEntry: vi.fn(), toggleCompanyHoliday: vi.fn(), getStats: vi.fn(), updateStats: vi.fn(),
- getCountries: vi.fn(), getHolidays: vi.fn(),
- },
-}));
-vi.mock('../../src/services/vacayService', () => svc);
-
-import vacayRoutes from '../../src/routes/vacay';
-import { VacayModule } from '../../src/nest/vacay/vacay.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('S1 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/addons/vacay', vacayRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [VacayModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- svc.getActivePlanId.mockReturnValue(10);
- svc.getActivePlan.mockReturnValue({ id: 10 });
- svc.getPlanUsers.mockReturnValue([{ id: 1 }]);
- svc.getPlanData.mockReturnValue({ plan: { id: 10 }, users: [] });
- svc.addHolidayCalendar.mockReturnValue({ id: 1, region: 'DE-BY' });
- svc.listYears.mockReturnValue([2026]);
- svc.addYear.mockReturnValue([2026, 2027]);
- svc.getEntries.mockReturnValue({ entries: [] });
- svc.toggleEntry.mockReturnValue({ action: 'added' });
- svc.getStats.mockReturnValue({ used: 5 });
- svc.getAvailableUsers.mockReturnValue([{ id: 2 }]);
- svc.sendInvite.mockReturnValue({});
- svc.getCountries.mockResolvedValue({ data: [{ code: 'DE' }] });
- svc.getHolidays.mockResolvedValue({ data: [{ date: '2026-01-01' }] });
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET /plan', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/plan' }));
- it('POST /plan/holiday-calendars (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/plan/holiday-calendars', body: { region: 'DE-BY', label: 'Bayern' } }));
- it('POST /plan/holiday-calendars missing region (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/plan/holiday-calendars', body: {} }));
- it('PUT /color in-plan (200)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/vacay/color', body: { color: '#fff' } }));
- it('PUT /color not in plan (403)', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/vacay/color', body: { color: '#fff', target_user_id: 99 } }));
- it('POST /invite (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/invite', body: { user_id: 2 } }));
- it('POST /invite missing user_id (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/invite', body: {} }));
- it('POST /dissolve (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/dissolve' }));
- it('GET /available-users', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/available-users' }));
- it('GET /years', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/years' }));
- it('POST /years (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/years', body: { year: 2027 } }));
- it('POST /years missing (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/years', body: {} }));
- it('GET /entries/:year', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/entries/2026' }));
- it('POST /entries/toggle (200)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/entries/toggle', body: { date: '2026-07-01' } }));
- it('POST /entries/toggle missing date (400)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/entries/toggle', body: {} }));
- it('GET /stats/:year', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/stats/2026' }));
- it('GET /holidays/countries', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/holidays/countries' }));
- it('GET /holidays/:year/:country', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/holidays/2026/DE' }));
-});
diff --git a/server/tests/parity/l9-packing.parity.test.ts b/server/tests/parity/l9-packing.parity.test.ts
deleted file mode 100644
index ca899390..00000000
--- a/server/tests/parity/l9-packing.parity.test.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * S2 parity — packing (trip-scoped).
- *
- * Fires the same request at the legacy Express /api/trips/:tripId/packing route
- * (mounted with mergeParams) and the migrated Nest controller, with
- * packingService, the permission check, the WebSocket broadcast and auth all
- * mocked identically for both. Asserts client-identical status + body, including
- * the trip-access 404, the permission 403, and POST /apply-template staying 200.
- */
-import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
-import express from 'express';
-import type { Server } from 'http';
-import { Test } from '@nestjs/testing';
-import { expectParity } from './parity';
-
-const { fixedUser, trip } = vi.hoisted(() => ({
- fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
- trip: { id: 5, user_id: 1 },
-}));
-
-vi.mock('../../src/db/database', () => ({
- db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
- closeDb: () => {},
- reinitialize: () => {},
-}));
-
-vi.mock('../../src/middleware/auth', () => ({
- authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
- (req as express.Request & { user: unknown }).user = fixedUser;
- next();
- },
- extractToken: () => 'token',
- verifyJwtAndLoadUser: () => fixedUser,
-}));
-
-vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
-
-const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
-vi.mock('../../src/services/permissions', () => ({ checkPermission }));
-
-const { svc } = vi.hoisted(() => ({
- svc: {
- verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(),
- deleteItem: vi.fn(), bulkImport: vi.fn(), reorderItems: vi.fn(), listBags: vi.fn(),
- createBag: vi.fn(), updateBag: vi.fn(), deleteBag: vi.fn(), applyTemplate: vi.fn(),
- saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(),
- updateCategoryAssignees: vi.fn(), reorderBags: vi.fn(),
- },
-}));
-vi.mock('../../src/services/packingService', () => svc);
-
-import packingRoutes from '../../src/routes/packing';
-import { PackingModule } from '../../src/nest/packing/packing.module';
-import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
-
-describe('S2 parity (Express vs Nest)', () => {
- let expressServer: express.Express;
- let nestServer: Server;
- let nestApp: Awaited>;
-
- function buildExpress() {
- const app = express();
- app.use(express.json());
- app.use('/api/trips/:tripId/packing', packingRoutes);
- return app;
- }
-
- async function buildNest() {
- const moduleRef = await Test.createTestingModule({ imports: [PackingModule] }).compile();
- const nest = moduleRef.createNestApplication();
- nest.useGlobalFilters(new TrekExceptionFilter());
- await nest.init();
- return nest;
- }
-
- beforeAll(async () => {
- expressServer = buildExpress();
- nestApp = await buildNest();
- nestServer = nestApp.getHttpServer();
- svc.listItems.mockReturnValue([{ id: 1, name: 'Socks' }]);
- svc.createItem.mockReturnValue({ id: 9, name: 'Socks' });
- svc.bulkImport.mockReturnValue([{ id: 1 }]);
- svc.updateItem.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null));
- svc.listBags.mockReturnValue([{ id: 1 }]);
- svc.createBag.mockReturnValue({ id: 2 });
- svc.applyTemplate.mockReturnValue([{ id: 1 }]);
- svc.getCategoryAssignees.mockReturnValue([]);
- });
-
- beforeEach(() => {
- svc.verifyTripAccess.mockReturnValue(trip);
- checkPermission.mockReturnValue(true);
- });
-
- afterAll(async () => {
- await nestApp.close();
- });
-
- it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/packing' }));
-
- it('GET / 404 when trip not accessible', () => {
- svc.verifyTripAccess.mockReturnValue(undefined);
- return expectParity(expressServer, nestServer, { path: '/api/trips/5/packing' });
- });
-
- it('POST / create (201)', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: { name: 'Socks' } }));
-
- it('POST / 403 without permission', () => {
- checkPermission.mockReturnValue(false);
- return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: { name: 'Socks' } });
- });
-
- it('POST / 400 missing name', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: {} }));
-
- it('POST /import 400 empty', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/import', body: { items: [] } }));
-
- it('PUT /reorder', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/packing/reorder', body: { orderedIds: [1, 2] } }));
-
- it('PUT /:id 404 when item missing', () =>
- expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/packing/77', body: { name: 'X' } }));
-
- it('GET /bags', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/packing/bags' }));
-
- it('POST /bags 400 blank name', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/bags', body: { name: ' ' } }));
-
- it('POST /apply-template/:id stays 200', () =>
- expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/apply-template/t1' }));
-
- it('GET /category-assignees', () =>
- expectParity(expressServer, nestServer, { path: '/api/trips/5/packing/category-assignees' }));
-});
diff --git a/server/tests/parity/parity.ts b/server/tests/parity/parity.ts
deleted file mode 100644
index 47275b54..00000000
--- a/server/tests/parity/parity.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import request from 'supertest';
-import { expect } from 'vitest';
-import type { Server } from 'http';
-
-export interface ParityRequest {
- method?: 'get' | 'post' | 'put' | 'patch' | 'delete';
- path: string;
- query?: Record;
- body?: unknown;
- /** Request headers (e.g. a Cookie/Authorization) applied to BOTH stacks. */
- headers?: Record;
-}
-
-/**
- * Reusable Nest-vs-Express parity harness.
- *
- * Fires the same HTTP request at the legacy Express app and the migrated Nest app
- * and asserts the response is client-identical — same status code and same JSON
- * body. With the underlying service mocked identically for both, any difference is
- * purely framework-layer (routing, validation, error envelope), which is exactly
- * what a migration must not change. Use one assertion per migrated route/case.
- */
-export async function expectParity(
- expressServer: Server | Express.Application,
- nestServer: Server,
- req: ParityRequest,
-): Promise {
- const fire = (target: Server | Express.Application) => {
- const method = req.method ?? 'get';
- let r = request(target as never)[method](req.path);
- if (req.headers) for (const [k, v] of Object.entries(req.headers)) r = r.set(k, v);
- if (req.query) r = r.query(req.query);
- if (req.body !== undefined) r = r.send(req.body as object);
- return r;
- };
-
- const [ex, ne] = await Promise.all([fire(expressServer), fire(nestServer)]);
-
- const label = `${(req.method ?? 'GET').toUpperCase()} ${req.path}`;
- expect(ne.status, `${label}: status mismatch`).toBe(ex.status);
- expect(ne.body, `${label}: body mismatch`).toEqual(ex.body);
-}
diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts
index 9d831cff..3da8eacb 100644
--- a/server/tests/unit/mcp/tools-prompts.test.ts
+++ b/server/tests/unit/mcp/tools-prompts.test.ts
@@ -71,8 +71,10 @@ beforeEach(() => {
isAddonEnabledMock.mockReturnValue(true);
// Default mock: returns a trip-summary-shaped value from the real in-memory DB
- // so that the trip title / existence match what tests insert, but budget/packing
- // are arrays (as prompts.ts expects), not the object shape getTripSummary now returns.
+ // so the trip title / existence match what tests insert. `budget` mirrors the
+ // real getTripSummary object shape ({ items, total, ... }) that prompts.ts reads
+ // via budget.items/budget.total; packing stays an array (the packing prompt
+ // tolerates it).
mockGetTripSummary.mockImplementation((tripId: any) => {
const trip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
if (!trip) return null;
@@ -87,8 +89,13 @@ beforeEach(() => {
trip,
days: [],
members,
- budget: budgetRows, // array shape expected by prompts.ts
- packing: packingRows, // array shape expected by prompts.ts
+ budget: {
+ items: budgetRows,
+ item_count: budgetRows.length,
+ total: budgetRows.reduce((sum, i) => sum + (i.total_price || 0), 0),
+ currency: trip.currency,
+ },
+ packing: packingRows, // array shape; packing prompt tolerates it
reservations: [],
collabNotes: [],
};
diff --git a/server/tests/unit/nest/strangler.test.ts b/server/tests/unit/nest/strangler.test.ts
deleted file mode 100644
index ade88dd1..00000000
--- a/server/tests/unit/nest/strangler.test.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { describe, it, expect, afterEach } from 'vitest';
-import { getNestPrefixes, makeNestPathMatcher } from '../../../src/nest/strangler';
-
-describe('strangler toggle', () => {
- const original = process.env.NEST_PREFIXES;
- afterEach(() => {
- if (original === undefined) delete process.env.NEST_PREFIXES;
- else process.env.NEST_PREFIXES = original;
- });
-
- it('defaults to the migrated prefixes when NEST_PREFIXES is unset', () => {
- delete process.env.NEST_PREFIXES;
- expect(getNestPrefixes()).toEqual([
- '/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',
- '/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/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',
- ]);
- });
-
- it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
- process.env.NEST_PREFIXES = '/api/weather, /api/airports';
- expect(getNestPrefixes()).toEqual(['/api/weather', '/api/airports']);
- });
-
- it('treats an empty NEST_PREFIXES as "all routes on legacy"', () => {
- process.env.NEST_PREFIXES = '';
- expect(getNestPrefixes()).toEqual([]);
- });
-
- it('matches exact prefixes and subpaths but not lookalikes', () => {
- const match = makeNestPathMatcher(['/api/_nest']);
- expect(match('/api/_nest')).toBe(true);
- expect(match('/api/_nest/health')).toBe(true);
- expect(match('/api/_nestxyz')).toBe(false);
- expect(match('/api/health')).toBe(false);
- });
-
- it('exact prefixes (trailing |) match the path only, not sub-paths', () => {
- const match = makeNestPathMatcher(['/api/trips|', '/api/trips/:tripId|', '/api/trips/:tripId/members']);
- expect(match('/api/trips')).toBe(true);
- expect(match('/api/trips/5')).toBe(true);
- expect(match('/api/trips/5/members')).toBe(true);
- expect(match('/api/trips/5/members/2')).toBe(true);
- // Not-yet-migrated nested mounts stay on Express:
- expect(match('/api/trips/5/collab')).toBe(false);
- expect(match('/api/trips/5/files')).toBe(false);
- expect(match('/api/trips/5/cover')).toBe(false);
- });
-
- it('routes auth sub-paths via their own explicit prefixes (no broad /api/auth catch-all)', () => {
- // The account prefixes alone must NOT swallow the separately-mounted oidc flow:
- const accountOnly = makeNestPathMatcher(['/api/auth/login', '/api/auth/me', '/api/auth/mfa', '/api/auth/mcp-tokens']);
- expect(accountOnly('/api/auth/login')).toBe(true);
- expect(accountOnly('/api/auth/me/password')).toBe(true);
- expect(accountOnly('/api/auth/mfa/verify-login')).toBe(true);
- expect(accountOnly('/api/auth/mcp-tokens/abc')).toBe(true);
- expect(accountOnly('/api/auth/oidc')).toBe(false);
- expect(accountOnly('/api/auth/oidc/callback')).toBe(false);
- // oidc is matched only by its own prefix (A2):
- const withOidc = makeNestPathMatcher(['/api/auth/oidc']);
- expect(withOidc('/api/auth/oidc/login')).toBe(true);
- expect(withOidc('/api/auth/oidc/callback')).toBe(true);
- });
-
- it('routes the OAuth public endpoints to Nest but leaves the SDK mounts on Express (A3)', () => {
- const match = makeNestPathMatcher(['/oauth/token', '/oauth/userinfo', '/oauth/revoke', '/api/oauth']);
- expect(match('/oauth/token')).toBe(true);
- expect(match('/oauth/userinfo')).toBe(true);
- expect(match('/oauth/revoke')).toBe(true);
- expect(match('/api/oauth/clients')).toBe(true);
- expect(match('/api/oauth/authorize/validate')).toBe(true);
- // The MCP SDK handlers must stay on Express:
- expect(match('/oauth/authorize')).toBe(false);
- expect(match('/oauth/register')).toBe(false);
- expect(match('/oauth/consent')).toBe(false);
- });
-
- it('matches a pattern prefix with :param without capturing sibling routes', () => {
- const match = makeNestPathMatcher(['/api/trips/:tripId/packing']);
- expect(match('/api/trips/5/packing')).toBe(true);
- expect(match('/api/trips/5/packing/bags')).toBe(true);
- expect(match('/api/trips/abc/packing/123')).toBe(true);
- // Sibling trip routes stay on Express:
- expect(match('/api/trips/5/days')).toBe(false);
- expect(match('/api/trips/5/places')).toBe(false);
- expect(match('/api/trips/5')).toBe(false);
- expect(match('/api/trips/5/packingx')).toBe(false);
- });
-});
diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts
index 82b14d55..09aa7fe6 100644
--- a/server/tests/unit/services/journeyService.test.ts
+++ b/server/tests/unit/services/journeyService.test.ts
@@ -17,7 +17,15 @@ const { testDb, dbMock } = vi.hoisted(() => {
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
- canAccessTrip: () => null,
+ // Mirror the real canAccessTrip semantics against the test DB (owner or member
+ // → truthy access row, else undefined) so addTripToJourney's trip-access guard
+ // behaves as in production. (Was an unused `() => null` stub before the guard existed.)
+ canAccessTrip: (tripId: number | string, 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: () => false,
};
return { testDb: db, dbMock: mock };
@@ -417,6 +425,22 @@ describe('addTripToJourney / removeTripFromJourney', () => {
expect(link).toBeDefined();
});
+ it('JOURNEY-SVC-024b: refuses to link a trip the caller cannot access (IDOR guard)', () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ // A trip owned by someone else, that `user` is not a member of.
+ const foreignTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip" });
+
+ const result = addTripToJourney(journey.id, foreignTrip.id, user.id);
+
+ expect(result).toBe(false);
+ const link = testDb.prepare(
+ 'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
+ ).get(journey.id, foreignTrip.id);
+ expect(link).toBeUndefined();
+ });
+
it('JOURNEY-SVC-025: syncs places as skeleton entries when linking a trip', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
diff --git a/server/tests/websocket/connection.test.ts b/server/tests/websocket/connection.test.ts
index 4d5b6f76..24b60910 100644
--- a/server/tests/websocket/connection.test.ts
+++ b/server/tests/websocket/connection.test.ts
@@ -39,27 +39,31 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
+ DEFAULT_LANGUAGE: 'en',
}));
-import { createApp } from '../../src/app';
+import type { INestApplication } from '@nestjs/common';
+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 } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
-import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { setupWebSocket } from '../../src/websocket';
import { createEphemeralToken } from '../../src/services/ephemeralTokens';
let server: http.Server;
let wsUrl: string;
+let nestApp: INestApplication;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
- const app = createApp();
- server = http.createServer(app);
+ // Real WebSocket against the unified NestJS app (Express is gone). buildApp owns
+ // the same composition production uses; we attach the real ws server to it.
+ nestApp = await buildApp();
+ server = http.createServer(nestApp.getHttpAdapter().getInstance());
setupWebSocket(server);
await new Promise(resolve => server.listen(0, resolve));
@@ -71,13 +75,13 @@ afterAll(async () => {
await new Promise((resolve, reject) =>
server.close(err => err ? reject(err) : resolve())
);
+ await nestApp.close();
testDb.close();
});
beforeEach(() => {
resetTestDb(testDb);
- loginAttempts.clear();
- mfaAttempts.clear();
+ resetRateLimits(nestApp);
});
/** Buffered WebSocket wrapper that never drops messages. */