Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.
This commit is contained in:
Maurice
2026-05-31 13:29:22 +02:00
parent fc7d8b5d12
commit bfe52579df
138 changed files with 2289 additions and 12666 deletions
@@ -1,95 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
const CURRENCIES = [
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
]
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
export default function CurrencyWidget() {
const { t, locale } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
const [amount, setAmount] = useState('100')
const [rate, setRate] = useState(null)
const [loading, setLoading] = useState(false)
const fetchRate = useCallback(async () => {
if (from === to) { setRate(1); return }
setLoading(true)
try {
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
const data = await resp.json()
setRate(data.rates?.[to] || null)
} catch { setRate(null) }
finally { setLoading(false) }
}, [from, to])
useEffect(() => { fetchRate() }, [fetchRate])
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
const swap = () => { setFrom(to); setTo(from) }
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
const formatNumber = (num) => {
if (!num || num === '—') return '—'
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const result = rawResult
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Amount */}
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
/>
</div>
{/* From / Swap / To */}
<div className="flex items-center gap-2 mb-3">
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
<ArrowRightLeft size={13} />
</button>
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
</div>
{/* Result */}
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
</p>
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
</div>
</div>
)
}
@@ -1,149 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TimezoneWidget from './TimezoneWidget'
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
localStorage.clear()
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TimezoneWidget', () => {
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
render(<TimezoneWidget />)
expect(document.body).toBeInTheDocument()
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
render(<TimezoneWidget />)
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
expect(timeElements.length).toBeGreaterThan(0)
})
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
render(<TimezoneWidget />)
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
localStorage.clear()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
render(<TimezoneWidget />)
expect(screen.getByText('Berlin')).toBeInTheDocument()
expect(screen.queryByText('New York')).toBeNull()
})
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
// Find and click Berlin in the popular zones list
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
expect(screen.getByText('Berlin')).toBeInTheDocument()
// Panel should be closed
expect(screen.queryByText('Custom Timezone')).toBeNull()
})
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
// Type label and timezone
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'My City')
await user.type(tzInput, 'Europe/Paris')
// Click Add
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText('My City')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'Invalid/Timezone')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Default zones include New York (America/New_York)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'America/New_York')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
// Remove buttons for New York and Tokyo come after the Plus button
const allButtons = screen.getAllByRole('button')
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
await user.click(allButtons[1])
expect(screen.queryByText('New York')).toBeNull()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
})
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'Singapore')
await user.type(tzInput, 'Asia/Singapore')
await user.keyboard('{Enter}')
expect(await screen.findByText('Singapore')).toBeInTheDocument()
})
})
@@ -1,167 +0,0 @@
import { useState, useEffect } from 'react'
import { Clock, Plus, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
const POPULAR_ZONES = [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'London', tz: 'Europe/London' },
{ label: 'Berlin', tz: 'Europe/Berlin' },
{ label: 'Paris', tz: 'Europe/Paris' },
{ label: 'Dubai', tz: 'Asia/Dubai' },
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
{ label: 'Sydney', tz: 'Australia/Sydney' },
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
{ label: 'Chicago', tz: 'America/Chicago' },
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
{ label: 'Singapore', tz: 'Asia/Singapore' },
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
{ label: 'Seoul', tz: 'Asia/Seoul' },
{ label: 'Moscow', tz: 'Europe/Moscow' },
{ label: 'Cairo', tz: 'Africa/Cairo' },
]
function getTime(tz, locale, is12h) {
try {
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
} catch { return '—' }
}
function getOffset(tz) {
try {
const now = new Date()
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
const diff = (remote - local) / 3600000
const sign = diff >= 0 ? '+' : ''
return `${sign}${diff}h`
} catch { return '' }
}
export default function TimezoneWidget() {
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
]
})
const [now, setNow] = useState(Date.now())
const [showAdd, setShowAdd] = useState(false)
const [customLabel, setCustomLabel] = useState('')
const [customTz, setCustomTz] = useState('')
const [customError, setCustomError] = useState('')
useEffect(() => {
const i = setInterval(() => setNow(Date.now()), 10000)
return () => clearInterval(i)
}, [])
useEffect(() => {
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
}, [zones])
const isValidTz = (tz: string) => {
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
}
const addCustomZone = () => {
const tz = customTz.trim()
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
setZones([...zones, { label, tz }])
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
}
const addZone = (zone) => {
if (!zones.find(z => z.tz === zone.tz)) {
setZones([...zones, zone])
}
setShowAdd(false)
}
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
// Show abbreviated timezone name (e.g. CET, CEST, EST)
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<Plus size={12} />
</button>
</div>
{/* Local time */}
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
</div>
{/* Zone list */}
<div className="space-y-2">
{zones.map(z => (
<div key={z.tz} className="flex items-center justify-between group">
<div>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
</div>
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
<X size={11} />
</button>
</div>
))}
</div>
{/* Add zone dropdown */}
{showAdd && (
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
{/* Custom timezone */}
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
<div className="space-y-1.5">
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
<button onClick={addCustomZone}
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{t('dashboard.timezoneCustomAdd')}
</button>
</div>
</div>
{/* Popular zones */}
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
<button key={z.tz} onClick={() => addZone(z)}
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<span className="font-medium">{z.label}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -1,32 +0,0 @@
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import MobileTopHeader from './MobileTopHeader';
describe('MobileTopHeader', () => {
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
render(<MobileTopHeader title="Journeys" />);
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading.textContent).toBe('Journeys');
});
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
expect(screen.getByText('3 trips')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
const { container } = render(<MobileTopHeader title="Journeys" />);
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
expect(subtitleEl).not.toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
render(
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
);
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});
});
@@ -1,17 +0,0 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
}
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
return (
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
<div className="flex-1 min-w-0">
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
</div>
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
</div>
)
}
@@ -1,789 +0,0 @@
// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { server } from '../../../tests/helpers/msw/server';
import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore';
import { buildUser } from '../../../tests/helpers/factories';
import MemoriesPanel from './MemoriesPanel';
// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering
vi.mock('../../api/authUrl', () => ({
fetchImageAsBlob: vi.fn().mockResolvedValue('blob:mock-url'),
clearImageQueue: vi.fn(),
}));
const defaultProps = {
tripId: 1,
startDate: '2025-03-01',
endDate: '2025-03-10',
};
// Reusable provider object to configure a connected Immich instance
const immichAddon = {
id: 'immich',
name: 'Immich',
type: 'photo_provider',
enabled: true,
config: { status_get: '/integrations/memories/immich/status' },
};
// Handlers that simulate a connected provider with no photos/links
const connectedHandlers = [
http.get('/api/addons', () =>
HttpResponse.json({ addons: [immichAddon] })
),
http.get('/api/integrations/memories/immich/status', () =>
HttpResponse.json({ connected: true })
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
];
beforeEach(() => {
resetAllStores();
// Seed a default logged-in user
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }) });
});
describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-001: Shows loading state on initial render', () => {
// Use a delayed response so loading stays true long enough to assert
server.use(
http.get('/api/addons', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ addons: [] });
}),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Spinner is rendered synchronously — loading state starts as true
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// "Photo provider not connected" — no providers, falls back to generic label
await screen.findByText('Photo provider not connected');
});
it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'abc',
provider: 'immich',
user_id: 2,
username: 'Alice',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Alice's username is rendered as an avatar tooltip in the gallery
await screen.findByText('Alice');
});
it('FE-COMP-MEMORIESPANEL-004: Shows empty gallery state when connected but no photos', async () => {
server.use(...connectedHandlers);
render(<MemoriesPanel {...defaultProps} />);
// Provider is connected so the gallery renders — but no photos → empty state
await screen.findByText('No photos found');
});
it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
});
it('FE-COMP-MEMORIESPANEL-006: Sync button calls the sync endpoint', async () => {
let syncCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => {
syncCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
const syncBtn = screen.getByTitle('Sync album');
await userEvent.click(syncBtn);
await waitFor(() => expect(syncCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-007: Unlink button calls the delete endpoint', async () => {
let deleteCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => {
deleteCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
// The unlink button is only shown when link.user_id === currentUser.id
const unlinkBtn = screen.getByTitle('Unlink album');
await userEvent.click(unlinkBtn);
await waitFor(() => expect(deleteCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Default sort is ascending ("Oldest first")
const sortBtn = await screen.findByText('Oldest first');
await userEvent.click(sortBtn);
// After toggle, button label switches to "Newest first"
expect(screen.getByText('Newest first')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Wait for the empty gallery to load
await screen.findByText('No photos found');
// Both the header button and gallery CTA say "Add photos" — click the first
const addBtns = screen.getAllByText('Add photos');
await userEvent.click(addBtns[0]);
// Picker header is now visible
await screen.findByText('Select photos from Immich');
});
it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const addBtns = screen.getAllByText('Add photos');
await userEvent.click(addBtns[0]);
await screen.findByText('Select photos from Immich');
// Click Cancel in the picker header
await userEvent.click(screen.getByText('Cancel'));
// Gallery is restored
await screen.findByText('No photos found');
});
it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({ albums: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
// Album picker header appears
await screen.findByText('Select Immich Album');
});
it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 0,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Share-toggle button appears with correct title (not shared → "Share photos")
await screen.findByTitle('Share photos');
// "Private" label is shown on unshared own photos
expect(screen.getByText('Private')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-013: toggleSharing calls the PUT sharing endpoint', async () => {
let putCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 0,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => {
putCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
const shareBtn = await screen.findByTitle('Share photos');
await userEvent.click(shareBtn);
await waitFor(() => expect(putCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-014: removePhoto calls the DELETE photos endpoint', async () => {
let deleteCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => {
deleteCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
// Wait for the share/stop-sharing button to confirm the gallery has rendered
await screen.findByTitle('Stop sharing');
// The remove button is the second action button in the hover overlay — no title, just an X icon
// Get all buttons and click the one after the share toggle
const allBtns = screen.getAllByRole('button');
const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing');
// The remove button immediately follows the share button in the DOM
await userEvent.click(allBtns[shareIdx + 1]);
await waitFor(() => expect(deleteCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-015: Picker displays assets grouped by month', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Month group header appears after photos load
await screen.findByText(/March.*2025|2025.*March/);
});
it('FE-COMP-MEMORIESPANEL-016: Album picker lists available albums with asset count', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({
albums: [
{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Summer 2025');
// Asset count is rendered next to the album name
expect(screen.getByText(/42/)).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-017: ProviderTabs appear in picker when multiple providers are connected', async () => {
const immich2Addon = {
id: 'immich2',
name: 'Immich2',
type: 'photo_provider',
enabled: true,
config: { status_get: '/integrations/memories/immich2/status' },
};
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ addons: [immichAddon, immich2Addon] })
),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })),
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
// With multiple providers the picker header uses the "multiple" translation
await screen.findByText('Select Photos');
// Both provider name tabs are rendered inside the picker
expect(screen.getByRole('button', { name: 'Immich' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Immich2' })).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Location dropdown shows "All locations" option when there are 2+ distinct cities
await screen.findByText('All locations');
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-019: Full picker flow: select photo → confirm dialog → execute add', async () => {
let addPhotosCalled = false;
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
],
})
),
http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => {
addPhotosCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Wait for the picker asset thumbnail to render (ProviderImg sets src after blob resolves)
// img has alt="" so findByRole('img') won't work — use findByAltText instead
const thumbnail = await screen.findByAltText('');
// Click the thumbnail — bubbles up to the parent div's onClick to select it
await userEvent.click(thumbnail);
// "1 selected" count appears and "Add 1 photos" button is active
await screen.findByText(/1\s+selected/);
await userEvent.click(screen.getByText('Add 1 photos'));
// Confirm share dialog appears
await screen.findByText('Share with trip members?');
// Click the confirm "Share photos" button to execute
await userEvent.click(screen.getByText('Share photos'));
await waitFor(() => expect(addPhotosCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-020: "All photos" filter tab makes an unfiltered search', async () => {
let searchCount = 0;
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () => {
searchCount++;
return HttpResponse.json({ assets: [] });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Click "All photos" — triggers a second loadPickerPhotos(false) call
await userEvent.click(screen.getByText('All photos'));
await waitFor(() => expect(searchCount).toBeGreaterThan(1));
});
it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// "Trip dates" tab is absent when dates are not set
expect(screen.queryByText(/Trip dates/)).not.toBeInTheDocument();
expect(screen.getByText('All photos')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-022: Provider with no status_get URL shows not-connected', async () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({
addons: [
{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} },
],
})
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Provider name shown in the not-connected message when exactly 1 enabled provider
await screen.findByText('MyApp not connected');
});
it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'asset1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Gallery shows own photo — "Stop sharing" title confirms it's loaded
await screen.findByTitle('Stop sharing');
// Open picker from the header button (only 1 "Add photos" button since photos > 0)
await userEvent.click(screen.getByText('Add photos'));
await screen.findByText('Select photos from Immich');
// The asset already in the gallery shows the "Added" overlay in the picker
await screen.findByText('Added');
});
it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
const select = await screen.findByRole('combobox');
// Change filter to a specific city
await userEvent.selectOptions(select, 'Paris');
expect(select).toHaveValue('Paris');
});
it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 2,
username: 'Alice',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
// Other user's username is shown in parentheses
expect(screen.getByText('(Alice)')).toBeInTheDocument();
// Unlink button is NOT shown for another user's album link
expect(screen.queryByTitle('Unlink album')).not.toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-026: Linking an album calls the album-links POST endpoint', async () => {
let linkCalled = false;
// Track whether POST has been made so the GET can return different data
let albumLinked = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }],
})
),
http.post('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
linkCalled = true;
albumLinked = true;
return HttpResponse.json({ ok: true });
}),
// Return empty before POST, linked album after POST
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
if (!albumLinked) return HttpResponse.json({ links: [] });
return HttpResponse.json({
links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }],
});
}),
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () =>
HttpResponse.json({ ok: true })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Summer 2025');
// Click the album button to link it (album is not yet linked → button is enabled)
await userEvent.click(screen.getByText('Summer 2025'));
await waitFor(() => expect(linkCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({ albums: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Select Immich Album');
// Click Cancel to dismiss without linking
await userEvent.click(screen.getByText('Cancel'));
// Gallery is restored
await screen.findByText('No photos found');
});
});
File diff suppressed because it is too large Load Diff
@@ -1,65 +0,0 @@
import React, { useCallback, useState } from 'react'
import { Copy, Check } from 'lucide-react'
interface CopyButtonProps {
value: string
size?: number
title?: string
className?: string
onCopy?: () => void
}
// Button that morphs between copy icon and check icon for 1.5s after click.
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
const [copied, setCopied] = useState(false)
const handleClick = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(value)
setCopied(true)
onCopy?.()
window.setTimeout(() => setCopied(false), 1500)
} catch {
// noop
}
}, [value, onCopy])
return (
<button
type="button"
onClick={handleClick}
title={title}
className={className}
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: size + 12,
height: size + 12,
border: 'none',
background: 'transparent',
color: copied ? '#22c55e' : 'var(--text-muted)',
cursor: 'pointer',
borderRadius: 6,
}}
>
<Copy size={size} style={{
position: 'absolute',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
opacity: copied ? 0 : 1,
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
}} />
<Check size={size} style={{
position: 'absolute',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
opacity: copied ? 1 : 0,
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
strokeWidth: 2.5,
}} />
</button>
)
}
export default CopyButton
@@ -1,36 +0,0 @@
import React, { useState, type ImgHTMLAttributes } from 'react'
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
containerClassName?: string
containerStyle?: React.CSSProperties
}
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
export function LoadingImage({
containerClassName, containerStyle, className, style, onLoad, ...imgProps
}: LoadingImageProps): React.ReactElement {
const [loaded, setLoaded] = useState(false)
return (
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
{!loaded && (
<div
className="trek-skeleton"
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
aria-hidden
/>
)}
<img
{...imgProps}
className={className}
style={{
...style,
opacity: loaded ? 1 : 0,
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
}}
onLoad={e => { setLoaded(true); onLoad?.(e) }}
/>
</div>
)
}
export default LoadingImage
-43
View File
@@ -1,43 +0,0 @@
/**
* usePendingMutations — returns the set of entity IDs that have a pending
* or syncing mutation for a given trip.
*
* Components use this to render a clock/pending indicator on list rows.
* Polls Dexie every 2 s so the indicator clears automatically once synced.
*/
import { useState, useEffect } from 'react'
import { mutationQueue } from '../sync/mutationQueue'
const POLL_MS = 2_000
export function usePendingMutations(tripId: number): Set<number> {
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set())
useEffect(() => {
let cancelled = false
async function refresh() {
const pending = await mutationQueue.pending(tripId)
if (cancelled) return
const ids = new Set<number>()
for (const m of pending) {
// Extract entity id from the mutation URL (last numeric segment)
const match = m.url.match(/\/(\d+)$/)
if (match) ids.add(Number(match[1]))
// Also include tempId for offline-created items
if (m.tempId !== undefined) ids.add(m.tempId)
}
setPendingIds(ids)
}
refresh()
const timer = setInterval(refresh, POLL_MS)
return () => {
cancelled = true
clearInterval(timer)
}
}, [tripId])
return pendingIds
}
-63
View File
@@ -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]);
});
});
-1
View File
@@ -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"
},
+45
View File
@@ -0,0 +1,45 @@
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { AppModule } from './nest/app.module';
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes';
/**
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
* former Express app is gone. One builder is shared by the production bootstrap
* (index.ts) and the integration-test harness so the two can never drift.
*
* Composition order is load-bearing. Everything except the SPA index.html fallback
* is registered on the underlying Express instance BEFORE `app.init()`, because
* Nest's router terminates an unmatched request by throwing NotFoundException — it
* does NOT fall through to a route registered after init, so a post-init Express
* route is unreachable. The platform routes are all specific paths (/uploads/*,
* /api/health, /mcp, /.well-known/*, /oauth/{authorize,register,consent}) so they
* match their own requests and `next()` everything else through to the Nest
* controllers registered during init.
*
* 1. applyGlobalMiddleware — helmet/CSP, CORS, HSTS, forced-HTTPS, the global MFA
* policy, request logging + cookie-parser. `bodyParser: false` so Nest does its
* own parsing and the raw /mcp body reaches the MCP handler unparsed.
* 2. applyPlatformUploads — the static + guarded /uploads/* routes.
* 3. applyPlatformTransport — /api/health, the OAuth/MCP SDK + /.well-known
* metadata, the /mcp routes, the /oauth/consent COOP header.
* 4. applyPlatformStatic — the production built-client static assets (so a real
* asset request returns the file before the Nest router 404s it).
* 5. app.init() — registers every migrated /api domain (the Nest controllers).
*
* The SPA index.html fallback (unmatched GET → index.html in production) is the
* SpaFallbackFilter (APP_FILTER in AppModule); the global error envelope is the
* TrekExceptionFilter (also APP_FILTER).
*/
export async function buildApp(): Promise<INestApplication> {
const app = await NestFactory.create(AppModule, new ExpressAdapter());
const instance = app.getHttpAdapter().getInstance();
applyGlobalMiddleware(instance, { bodyParser: false });
applyPlatformUploads(instance);
applyPlatformTransport(instance);
applyPlatformStatic(instance);
await app.init();
return app;
}
+8 -46
View File
@@ -3,14 +3,8 @@ import 'dotenv/config';
import path from 'node:path';
import fs from 'node:fs';
import http from 'node:http';
import express from 'express';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { createApp } from './app';
import { AppModule } from './nest/app.module';
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
import { buildApp } from './bootstrap';
// Create upload and data directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
@@ -25,11 +19,6 @@ const tmpDir = path.join(__dirname, '../data/tmp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
// everything else falls through to this app via a fallback middleware.
const legacyApp = createApp();
import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
@@ -61,11 +50,7 @@ const onListen = () => {
'──────────────────────────────────────',
];
banner.forEach(l => console.log(l));
sLogInfo(
NEST_PREFIXES.length
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
: 'NestJS prefixes: none — all routes served by the legacy Express app',
);
sLogInfo('NestJS serving all routes (Express decommissioned)');
if (process.env.APP_URL) {
let parsedAppUrl: URL | null = null;
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
@@ -105,34 +90,13 @@ let server: http.Server;
let nestApp: INestApplication;
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
const NEST_PREFIXES = getNestPrefixes();
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
async function bootstrap(): Promise<void> {
// Nest runs on its own Express instance (bodyParser off so request bodies reach
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
// Nest body parsing is safe here: the dispatcher only forwards migrated
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
// is reached separately and never passes through Nest's parser.
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
const nestInstance = nestApp.getHttpAdapter().getInstance();
// Apply the SAME global request pipeline the legacy app uses (helmet/CSP, CORS,
// HSTS, forced-HTTPS, the global MFA policy, request logging + cookie-parser) so a
// migrated Nest route is protected identically to the legacy fallback. Without this
// the dispatcher forwards Nest paths straight to this instance, bypassing all of it.
// Nest does its own body parsing, so bodyParser:false avoids parsing twice.
applyGlobalMiddleware(nestInstance, { bodyParser: false });
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
await nestApp.init();
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
// only applies within migrated prefixes.
const top = express();
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
top.use(legacyApp);
server = http.createServer(top);
// The whole surface runs on the single NestJS app now (Express decommissioned):
// global pipeline + /uploads + every /api domain + the platform/transport routes
// (/mcp, /.well-known, OAuth SDK, SPA catch-all). buildApp() owns the composition
// order; it is shared with the integration-test harness so they can't drift.
nestApp = await buildApp();
server = http.createServer(nestApp.getHttpAdapter().getInstance());
if (HOST) server.listen(PORT, HOST, onListen);
else server.listen(PORT, onListen);
}
@@ -165,5 +129,3 @@ function shutdown(signal: string): void {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
export default legacyApp;
+1
View File
@@ -380,6 +380,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const shareLink = getJourneyShareLink(journeyId);
return ok({ shareLink });
}
@@ -0,0 +1,22 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AddonsService } from './addons.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
/**
* GET /api/addons — the enabled trip add-ons + photo providers feed.
* Byte-identical to the legacy inline handler in server/src/app.ts
* (authenticate-gated, returns { collabFeatures, addons: [...] }).
*
* Distinct from the addon sub-mounts /api/addons/atlas and /api/addons/vacay
* (their own Nest modules); the strangler routes only the EXACT /api/addons here.
*/
@Controller('api/addons')
@UseGuards(JwtAuthGuard)
export class AddonsController {
constructor(private readonly addons: AddonsService) {}
@Get()
list() {
return this.addons.list();
}
}
+14
View File
@@ -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 {}
+81
View File
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import type { Addon } from '../../types';
import { getCollabFeatures } from '../../services/adminService';
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
/**
* Thin wrapper around the enabled-addons + photo-provider read that the legacy
* inline `GET /api/addons` handler performed (server/src/app.ts). The SQL,
* ordering, boolean coercions and the merged photo-provider entries are
* reproduced 1:1 so the body is byte-identical for the client.
*/
@Injectable()
export class AddonsService {
list() {
const addons = db
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
const providers = db
.prepare(
`SELECT id, name, icon, enabled, sort_order
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id`,
)
.all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db
.prepare(
`SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id`,
)
.all() as Array<{
provider_id: string;
field_key: string;
label: string;
input_type: string;
placeholder?: string | null;
hint?: string | null;
required: number;
secret: number;
settings_key?: string | null;
payload_key?: string | null;
sort_order: number;
}>;
const fieldsByProvider = new Map<string, typeof fields>();
for (const field of fields) {
const arr = fieldsByProvider.get(field.provider_id) || [];
arr.push(field);
fieldsByProvider.set(field.provider_id, arr);
}
return {
collabFeatures: getCollabFeatures(),
addons: [
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
...providers.map((p) => ({
id: p.id,
name: p.name,
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
placeholder: f.placeholder || '',
hint: f.hint || null,
required: !!f.required,
secret: !!f.secret,
settings_key: f.settings_key || null,
payload_key: f.payload_key || null,
sort_order: f.sort_order,
})),
})),
],
};
}
}
+8 -1
View File
@@ -24,6 +24,7 @@ import { TodoModule } from './todo/todo.module';
import { CollabModule } from './collab/collab.module';
import { FilesModule } from './files/files.module';
import { PhotosModule } from './photos/photos.module';
import { MemoriesModule } from './memories/memories.module';
import { JourneyModule } from './journey/journey.module';
import { ShareModule } from './share/share.module';
import { SettingsModule } from './settings/settings.module';
@@ -32,7 +33,9 @@ import { AuthModule } from './auth/auth.module';
import { OidcModule } from './oidc/oidc.module';
import { OauthModule } from './oauth/oauth.module';
import { AdminModule } from './admin/admin.module';
import { AddonsModule } from './addons/addons.module';
import { TrekExceptionFilter } from './common/trek-exception.filter';
import { SpaFallbackFilter } from './platform/spa-fallback.filter';
import { IdempotencyInterceptor } from './common/idempotency.interceptor';
/**
@@ -40,13 +43,17 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
* (weather, notifications, ...) get registered here as they are migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule],
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule],
controllers: [HealthController],
providers: [
HealthService,
// Global error-envelope normaliser (DI-registered so it also catches
// framework-level exceptions like the not-found handler).
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
// SPA fallback: serves index.html for unmatched GETs in production (the Nest
// equivalent of the legacy Express app.get('*') catch-all). @Catch(NotFoundException)
// is more specific than TrekExceptionFilter, so Nest routes 404s here.
{ provide: APP_FILTER, useClass: SpaFallbackFilter },
// Replays the X-Idempotency-Key the client sends on every write, matching
// the legacy applyIdempotency middleware so retried mutations don't double-apply.
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
@@ -33,6 +33,7 @@ const NOTE_UPLOAD = {
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
}),
limits: { fileSize: MAX_NOTE_FILE_SIZE },
defParamCharset: 'utf8', // parity with legacy routes/collab.ts — preserve non-ASCII original filenames
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
const ext = path.extname(file.originalname).toLowerCase();
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
+45 -18
View File
@@ -1,42 +1,69 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { Response } from 'express';
import { MulterError } from 'multer';
/**
* Normalises every Nest exception to TREK's legacy error envelope so migrated
* routes are byte-identical for the client:
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
* This replaces Nest's default { statusCode, message, error } body, which the
* TREK client does not expect.
* routes are byte-identical for the client. This mirrors the legacy global
* Express error handler (server/src/app.ts) exactly:
* - multer errors -> 413 (LIMIT_FILE_SIZE) / 400, body { error: <multer message> }
* - { error, code? } bodies -> passed through unchanged (auth guards, ZodValidationPipe)
* - other HttpExceptions -> { error: <message> } at the same status
* - plain errors w/ statusCode/status -> that status, { error: <message> } for 4xx
* - everything else -> 500 { error: 'Internal server error' }
*
* Without the multer + statusCode handling, file-upload rejections (multer's
* LIMIT_FILE_SIZE and the fileFilter errors that carry `statusCode = 400`) would
* collapse to Nest's `{ statusCode, message, error }` 413 body or a 500, diverging
* from the legacy `{ error: 'File too large' }` (413) and `{ error: '<reason>' }` (400).
*/
@Catch()
export class TrekExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
// 1. Raw multer errors that slipped past @nestjs/platform-express's
// transformException (it leaves codes it does not recognise untouched).
// Legacy: LIMIT_FILE_SIZE -> 413, everything else -> 400, body { error: message }.
if (exception instanceof MulterError) {
const status = exception.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
res.status(status).json({ error: exception.message });
return;
}
if (exception instanceof HttpException) {
const status = exception.getStatus();
const body = exception.getResponse();
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
res.status(status).json(body);
if (body && typeof body === 'object') {
const obj = body as Record<string, unknown>;
// TREK-native shape ({ error } / { error, code } from guards + the Zod
// pipe): pass through verbatim. Nest's own exceptions instead carry the
// { statusCode, message, error } trio (incl. transformException's
// PayloadTooLargeException for LIMIT_FILE_SIZE) and must be normalised.
if ('error' in obj && !('statusCode' in obj) && !('message' in obj)) {
res.status(status).json(obj);
return;
}
const raw = obj.message ?? obj.error;
const message =
status < 500 ? (Array.isArray(raw) ? raw.join(', ') : String(raw ?? 'Error')) : 'Internal server error';
res.status(status).json({ error: message });
return;
}
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
const message =
status < 500
? Array.isArray(raw)
? raw.join(', ')
: String(raw ?? 'Error')
: 'Internal server error';
const message = status < 500 ? String(body ?? 'Error') : 'Internal server error';
res.status(status).json({ error: message });
return;
}
// Unknown/unhandled error — mirror the legacy 500 behaviour.
console.error('Unhandled error:', exception);
res.status(500).json({ error: 'Internal server error' });
// 2. Plain errors carrying an explicit status (the fileFilter rejections set
// `statusCode = 400`; transformException returns them unchanged). Legacy:
// status = err.statusCode || err.status || 500; 4xx exposes err.message.
const err = exception as { statusCode?: number; status?: number; message?: unknown } | null;
const status = (err && (err.statusCode || err.status)) || 500;
if (status >= 500) console.error('Unhandled error:', exception);
const message = status < 500 ? String(err?.message ?? 'Error') : 'Internal server error';
res.status(status).json({ error: message });
}
}
@@ -48,6 +48,11 @@ export class FilesDownloadController {
res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`);
}
res.sendFile(resolved);
// Serve with an explicit { root } + basename rather than an absolute path:
// under the Nest ExpressAdapter, res.sendFile(absolutePath) resolves the
// file relative to the (rewritten) req.url and fails with a spurious
// "Not Found", whereas the root-relative form streams correctly. The
// resolveFilePath guard above already pins this to the uploads dir.
res.sendFile(path.basename(resolved), { root: path.dirname(resolved) });
}
}
@@ -33,6 +33,7 @@ const UPLOAD = {
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
}),
limits: { fileSize: MAX_FILE_SIZE },
defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
const ext = path.extname(file.originalname).toLowerCase();
const reject = () => {
@@ -0,0 +1,193 @@
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { User } from '../../types';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../../services/auditLog';
/**
* /api/integrations/memories/immich — Immich connection, browse/search, asset
* proxy and album linking.
*
* Byte-identical to the legacy Express router (server/src/routes/memories/immich.ts):
* `/status` and `/test` answer 200 even on connection failure (the service shapes
* `{ connected: false, ... }`); `/settings` PUT validates with a 400; the asset
* routes do the 400 invalid-id guard then the canAccessUserPhoto 403 ('Forbidden')
* before streaming or returning info; the album sync answers 200 then broadcasts.
* The legacy `canAccessTrip` import there is dead code — intentionally not ported.
*/
@Controller('api/integrations/memories/immich')
@UseGuards(JwtAuthGuard)
export class ImmichMemoriesController {
constructor(private readonly memories: MemoriesService) {}
@Get('settings')
getSettings(@CurrentUser() user: User) {
return this.memories.immichGetConnectionSettings(user.id);
}
@Put('settings')
async putSettings(
@CurrentUser() user: User,
@Body() body: { immich_url?: string; immich_api_key?: string; auto_upload?: unknown },
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const { immich_url, immich_api_key, auto_upload } = body;
const result = await this.memories.immichSaveSettings(user.id, immich_url, immich_api_key, getClientIp(req));
if (!result.success) {
res.status(400).json({ error: result.error });
return;
}
if (typeof auto_upload === 'boolean') {
this.memories.immichSetAutoUpload(user.id, auto_upload);
}
if (result.warning) {
res.json({ success: true, warning: result.warning });
return;
}
res.json({ success: true });
}
@Get('status')
async getStatus(@CurrentUser() user: User) {
return this.memories.immichGetConnectionStatus(user.id);
}
@Post('test')
@HttpCode(200)
async test(@Body() body: { immich_url?: string; immich_api_key?: string }) {
const { immich_url, immich_api_key } = body;
if (!immich_url || !immich_api_key) {
return { connected: false, error: 'URL and API key required' };
}
return this.memories.immichTestConnection(immich_url, immich_api_key);
}
@Get('browse')
async browse(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
const result = await this.memories.immichBrowseTimeline(user.id);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ buckets: result.buckets });
}
@Post('search')
@HttpCode(200)
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const { from, to, size, page } = body as { from?: string; to?: string; size?: unknown; page?: unknown };
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.min(Number(size) || 50, 200);
const result = await this.memories.immichSearchPhotos(user.id, from, to, pageNum, pageSize);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
}
@Get('assets/:tripId/:assetId/:ownerId/info')
async assetInfo(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('assetId') assetId: string,
@Param('ownerId') ownerId: string,
@Res() res: Response,
): Promise<void> {
if (!this.memories.immichIsValidAssetId(assetId)) {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
res.status(403).json({ error: 'Forbidden' });
return;
}
const result = await this.memories.immichGetAssetInfo(user.id, assetId, Number(ownerId));
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json(result.data);
}
@Get('assets/:tripId/:assetId/:ownerId/thumbnail')
async assetThumbnail(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('assetId') assetId: string,
@Param('ownerId') ownerId: string,
@Res() res: Response,
): Promise<void> {
if (!this.memories.immichIsValidAssetId(assetId)) {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
res.status(403).json({ error: 'Forbidden' });
return;
}
await this.memories.immichStreamAsset(res, user.id, assetId, 'thumbnail', Number(ownerId));
}
@Get('assets/:tripId/:assetId/:ownerId/original')
async assetOriginal(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('assetId') assetId: string,
@Param('ownerId') ownerId: string,
@Res() res: Response,
): Promise<void> {
if (!this.memories.immichIsValidAssetId(assetId)) {
res.status(400).json({ error: 'Invalid asset ID' });
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) {
res.status(403).json({ error: 'Forbidden' });
return;
}
await this.memories.immichStreamAsset(res, user.id, assetId, 'original', Number(ownerId));
}
@Get('albums')
async albums(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
const result = await this.memories.immichListAlbums(user.id);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ albums: result.albums });
}
@Get('albums/:albumId/photos')
async albumPhotos(@CurrentUser() user: User, @Param('albumId') albumId: string, @Res() res: Response): Promise<void> {
const result = await this.memories.immichGetAlbumPhotos(user.id, albumId);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ assets: result.assets });
}
@Post('trips/:tripId/album-links/:linkId/sync')
@HttpCode(200)
async sync(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Headers('x-socket-id') sid: string,
@Res() res: Response,
): Promise<void> {
const result = await this.memories.immichSyncAlbumAssets(tripId, linkId, user.id, sid);
if (result.error) {
res.status(result.status!).json({ error: result.error });
return;
}
res.json({ success: true, added: result.added, total: result.total });
if (result.added! > 0) {
this.memories.broadcast(tripId, 'memories:updated', { userId: user.id }, sid);
}
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { MemoriesService } from './memories.service';
import { UnifiedMemoriesController } from './unified.controller';
import { ImmichMemoriesController } from './immich.controller';
import { SynologyMemoriesController } from './synology.controller';
/**
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
*
* Ports the legacy Express router (routes/memories/unified.ts, which composes
* immich.ts + synology.ts) to Nest, reusing services/memories/* unchanged. No
* module-level addon gate — enablement is per-provider-row inside the services,
* exactly as the legacy mount had it.
*/
@Module({
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
providers: [MemoriesService],
})
export class MemoriesModule {}
@@ -0,0 +1,183 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import {
listTripPhotos,
listTripAlbumLinks,
createTripAlbumLink,
removeAlbumLink,
addTripPhotos,
removeTripPhoto,
setTripPhotoSharing,
} from '../../services/memories/unifiedService';
import {
getConnectionSettings,
saveImmichSettings,
setImmichAutoUpload,
testConnection,
getConnectionStatus,
browseTimeline,
searchPhotos,
streamImmichAsset,
listAlbums,
getAlbumPhotos,
syncAlbumAssets,
getAssetInfo,
isValidAssetId,
} from '../../services/memories/immichService';
import {
getSynologySettings,
updateSynologySettings,
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
streamSynologyAsset,
} from '../../services/memories/synologyService';
import { canAccessUserPhoto } from '../../services/memories/helpersService';
import type { Selection } from '../../services/memories/helpersService';
import { broadcast } from '../../websocket';
/**
* Thin Nest wrapper around the existing memories (photo-providers) services.
* Every method delegates to the legacy `services/memories/*` code unchanged so
* the provider logic, the per-provider access checks and the streaming helpers
* behave byte-identically to the legacy Express routers. No new business logic
* lives here.
*/
@Injectable()
export class MemoriesService {
// ── Access check (reused by both provider asset routes) ──────────────────
canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
return canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId?: string): void {
broadcast(tripId, event, payload, socketId);
}
// ── Unified ──────────────────────────────────────────────────────────────
listTripPhotos(tripId: string, userId: number) {
return listTripPhotos(tripId, userId);
}
addTripPhotos(tripId: string, userId: number, shared: boolean, selections: Selection[], sid: string) {
return addTripPhotos(tripId, userId, shared, selections, sid);
}
setTripPhotoSharing(tripId: string, userId: number, photoId: number, shared: boolean) {
return setTripPhotoSharing(tripId, userId, photoId, shared);
}
removeTripPhoto(tripId: string, userId: number, photoId: number) {
return removeTripPhoto(tripId, userId, photoId);
}
listTripAlbumLinks(tripId: string, userId: number) {
return listTripAlbumLinks(tripId, userId);
}
createTripAlbumLink(tripId: string, userId: number, provider: unknown, albumId: unknown, albumName: unknown, passphrase?: string) {
return createTripAlbumLink(tripId, userId, provider, albumId, albumName, passphrase);
}
removeAlbumLink(tripId: string, linkId: string, userId: number) {
return removeAlbumLink(tripId, linkId, userId);
}
// ── Immich ─────────────────────────────────────────────────────────────────
immichGetConnectionSettings(userId: number) {
return getConnectionSettings(userId);
}
immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null) {
return saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
}
immichSetAutoUpload(userId: number, enabled: boolean): void {
setImmichAutoUpload(userId, enabled);
}
immichGetConnectionStatus(userId: number) {
return getConnectionStatus(userId);
}
immichTestConnection(immichUrl: string, immichApiKey: string) {
return testConnection(immichUrl, immichApiKey);
}
immichBrowseTimeline(userId: number) {
return browseTimeline(userId);
}
immichSearchPhotos(userId: number, from: string | undefined, to: string | undefined, page: number, size: number) {
return searchPhotos(userId, from, to, page, size);
}
immichIsValidAssetId(assetId: string): boolean {
return isValidAssetId(assetId);
}
immichGetAssetInfo(userId: number, assetId: string, ownerId: number) {
return getAssetInfo(userId, assetId, ownerId);
}
immichStreamAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) {
return streamImmichAsset(res, userId, assetId, kind, ownerId);
}
immichListAlbums(userId: number) {
return listAlbums(userId);
}
immichGetAlbumPhotos(userId: number, albumId: string) {
return getAlbumPhotos(userId, albumId);
}
immichSyncAlbumAssets(tripId: string, linkId: string, userId: number, sid: string) {
return syncAlbumAssets(tripId, linkId, userId, sid);
}
// ── Synology ────────────────────────────────────────────────────────────────
synologyGetSettings(userId: number) {
return getSynologySettings(userId);
}
synologyUpdateSettings(userId: number, url: string, username: string, password: string, skipSsl: boolean) {
return updateSynologySettings(userId, url, username, password, skipSsl);
}
synologyGetStatus(userId: number) {
return getSynologyStatus(userId);
}
synologyTestConnection(userId: number, url: string, username: string, password: string, otp: string, skipSsl: boolean) {
return testSynologyConnection(userId, url, username, password, otp, skipSsl);
}
synologyListAlbums(userId: number) {
return listSynologyAlbums(userId);
}
synologyGetAlbumPhotos(userId: number, albumId: string, passphrase?: string) {
return getSynologyAlbumPhotos(userId, albumId, passphrase);
}
synologySyncAlbumLink(userId: number, tripId: string, linkId: string, sid: string) {
return syncSynologyAlbumLink(userId, tripId, linkId, sid);
}
synologySearchPhotos(userId: number, from: string | undefined, to: string | undefined, offset: number, limit: number) {
return searchSynologyPhotos(userId, from, to, offset, limit);
}
synologyGetAssetInfo(userId: number, photoId: string, ownerId: number, passphrase?: string) {
return getSynologyAssetInfo(userId, photoId, ownerId, passphrase);
}
synologyStreamAsset(res: Response, userId: number, ownerId: number, photoId: string, kind: 'thumbnail' | 'original', size: string, passphrase?: string) {
return streamSynologyAsset(res, userId, ownerId, photoId, kind, size, passphrase);
}
}
@@ -0,0 +1,170 @@
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import type { User } from '../../types';
import type { ServiceResult } from '../../services/memories/helpersService';
import { fail, success } from '../../services/memories/helpersService';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
function _parseStringBodyField(value: unknown): string {
return String(value ?? '').trim();
}
function _parseNumberBodyField(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
/**
* /api/integrations/memories/synologyphotos — Synology Photos connection,
* search, albums and asset proxy.
*
* Byte-identical to the legacy Express router (server/src/routes/memories/synology.ts):
* every response goes through the service `ServiceResult` envelope (success →
* `res.json(data)` at 200, error → status + `{ error }`); `/status` and `/test`
* always answer 200 (the service shapes `{ connected: false, error }` on
* failure); the asset routes use the distinct 403 string "You don't have access
* to this photo"; `/info` is declared before the catch-all `/:kind` so the
* literal route wins as Express ordered it; lenient hand-rolled coercion is kept.
*/
@Controller('api/integrations/memories/synologyphotos')
@UseGuards(JwtAuthGuard)
export class SynologyMemoriesController {
constructor(private readonly memories: MemoriesService) {}
private handle<T>(res: Response, result: ServiceResult<T>): void {
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
} else {
res.json(result.data);
}
}
@Get('settings')
async getSettings(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
this.handle(res, await this.memories.synologyGetSettings(user.id));
}
@Put('settings')
async putSettings(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username) {
this.handle(res, fail('URL and username are required', 400));
} else {
this.handle(res, await this.memories.synologyUpdateSettings(user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
}
}
@Get('status')
async getStatus(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
this.handle(res, await this.memories.synologyGetStatus(user.id));
}
@Post('test')
@HttpCode(200)
async test(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = [];
if (!synology_url) missingFields.push('URL');
if (!synology_username) missingFields.push('Username');
if (!synology_password) missingFields.push('Password');
this.handle(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
} else {
this.handle(res, await this.memories.synologyTestConnection(user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
}
}
@Get('albums')
async albums(@CurrentUser() user: User, @Res() res: Response): Promise<void> {
this.handle(res, await this.memories.synologyListAlbums(user.id));
}
@Get('albums/:albumId/photos')
async albumPhotos(@CurrentUser() user: User, @Param('albumId') albumId: string, @Query('passphrase') passphraseRaw: string | undefined, @Res() res: Response): Promise<void> {
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
this.handle(res, await this.memories.synologyGetAlbumPhotos(user.id, albumId, passphrase));
}
@Post('trips/:tripId/album-links/:linkId/sync')
@HttpCode(200)
async sync(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Headers('x-socket-id') sid: string,
@Res() res: Response,
): Promise<void> {
this.handle(res, await this.memories.synologySyncAlbumLink(user.id, tripId, linkId, sid));
}
@Post('search')
@HttpCode(200)
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
let offset = _parseNumberBodyField(body.offset, 0);
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if (size > 0) limit = size;
if (page > 0) offset = page * limit;
this.handle(res, await this.memories.synologySearchPhotos(user.id, from || undefined, to || undefined, offset, limit));
}
@Get('assets/:tripId/:photoId/:ownerId/info')
async assetInfo(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('photoId') photoId: string,
@Param('ownerId') ownerId: string,
@Query('passphrase') passphraseRaw: string | undefined,
@Res() res: Response,
): Promise<void> {
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
this.handle(res, fail("You don't have access to this photo", 403));
} else {
this.handle(res, await this.memories.synologyGetAssetInfo(user.id, photoId, Number(ownerId), passphrase));
}
}
@Get('assets/:tripId/:photoId/:ownerId/:kind')
async asset(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('photoId') photoId: string,
@Param('ownerId') ownerId: string,
@Param('kind') kind: string,
@Query('size') sizeRaw: string | undefined,
@Query('passphrase') passphraseRaw: string | undefined,
@Res() res: Response,
): Promise<void> {
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
const rawSize = String(sizeRaw ?? 'sm');
const size = (VALID_SIZES as readonly string[]).includes(rawSize) ? rawSize : 'sm';
const passphrase = passphraseRaw ? String(passphraseRaw) : undefined;
if (kind !== 'thumbnail' && kind !== 'original') {
this.handle(res, fail('Invalid asset kind', 400));
return;
}
if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
this.handle(res, fail("You don't have access to this photo", 403));
} else {
await this.memories.synologyStreamAsset(res, user.id, Number(ownerId), photoId, kind, String(size), passphrase);
}
}
}
@@ -0,0 +1,123 @@
import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Post, Put, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import type { User } from '../../types';
import type { Selection } from '../../services/memories/helpersService';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
/**
* /api/integrations/memories/unified — provider-agnostic trip photo + album-link
* management.
*
* Byte-identical to the legacy Express router (server/src/routes/memories/unified.ts):
* bare `authenticate` (JwtAuthGuard), success bodies on 200, and the per-result
* error envelope `{ error }` at `result.error.status` reused from the unified
* service. Lenient hand-rolled body coercion is preserved — no Zod here.
*/
@Controller('api/integrations/memories/unified')
@UseGuards(JwtAuthGuard)
export class UnifiedMemoriesController {
constructor(private readonly memories: MemoriesService) {}
@Get('trips/:tripId/photos')
listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
const result = this.memories.listTripPhotos(tripId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ photos: result.data });
}
@Post('trips/:tripId/photos')
@HttpCode(200)
async addPhotos(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Headers('x-socket-id') sid: string,
@Res() res: Response,
): Promise<void> {
const selections: Selection[] = Array.isArray(body?.selections) ? (body.selections as Selection[]) : [];
const shared = body?.shared === undefined ? true : !!body?.shared;
const result = await this.memories.addTripPhotos(tripId, user.id, shared, selections, sid);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true, added: result.data.added });
}
@Put('trips/:tripId/photos/sharing')
async setSharing(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Res() res: Response,
): Promise<void> {
const result = await this.memories.setTripPhotoSharing(tripId, user.id, Number(body?.photo_id), body?.shared as boolean);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true });
}
@Delete('trips/:tripId/photos')
async removePhoto(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Res() res: Response,
): Promise<void> {
const result = this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id));
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true });
}
@Get('trips/:tripId/album-links')
listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
const result = this.memories.listTripAlbumLinks(tripId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ links: result.data });
}
@Post('trips/:tripId/album-links')
@HttpCode(200)
createAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Res() res: Response,
): void {
const passphrase = body?.passphrase ? String(body.passphrase) : undefined;
const result = this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true });
}
@Delete('trips/:tripId/album-links/:linkId')
removeAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Res() res: Response,
): void {
const result = this.memories.removeAlbumLink(tripId, linkId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
}
res.json({ success: true });
}
}
@@ -2,62 +2,39 @@ import express, { Request, Response, NextFunction } from 'express';
import path from 'node:path';
import fs from 'node:fs';
import multer from 'multer';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
import { db } from './db/database';
import authRoutes from './routes/auth';
import tripsRoutes from './routes/trips';
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
import placesRoutes from './routes/places';
import assignmentsRoutes from './routes/assignments';
import packingRoutes from './routes/packing';
import todoRoutes from './routes/todo';
import tagsRoutes from './routes/tags';
import categoriesRoutes from './routes/categories';
import adminRoutes from './routes/admin';
import mapsRoutes from './routes/maps';
import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
import settingsRoutes from './routes/settings';
import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab';
import backupRoutes from './routes/backup';
import oidcRoutes from './routes/oidc';
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified';
import photoRoutes from './routes/photos';
import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey';
import journeyPublicRoutes from './routes/journeyPublic';
import publicConfigRoutes from './routes/publicConfig';
import systemNoticesRoutes from './routes/systemNotices';
import { mcpHandler } from './mcp';
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
import { Addon, AuthRequest } from './types';
import { getUpcomingReservations } from './services/reservationService';
import { getPhotoProviderConfig } from './services/memories/helpersService';
import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons';
import { ALL_SCOPES } from './mcp/scopes';
import { verifyJwtAndLoadUser } from '../../middleware/auth';
import { db } from '../../db/database';
import { mcpHandler } from '../../mcp';
import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { ALL_SCOPES } from '../../mcp/scopes';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
import { getMcpSafeUrl } from './services/notifications';
import { getMcpSafeUrl } from '../../services/notifications';
export function createApp(): express.Application {
const app = express();
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
//
// IMPORTANT — path resolution: the original blocks lived in src/app.ts, where __dirname
// resolves to the directory of app.js (one level above the uploads/public anchor), so they
// used '../uploads/...' and '../public'. This file lives three levels deeper
// (src/nest/platform/), so __dirname is three levels deeper too. The relative prefixes are
// therefore '../../../uploads/...' and '../../../public' — which resolve to the EXACT same
// absolute paths as before. This is the only intentional change; everything else is byte-for-byte
// identical. (rootDir/outDir preserve the tree, so the offset holds in both source/test and
// compiled/dist execution — matching the other nest controllers that use '../../../uploads/...'.)
applyGlobalMiddleware(app);
const UPLOADS_DIR = path.join(__dirname, '../../../uploads');
export const PUBLIC_DIR = path.join(__dirname, '../../../public');
/**
* Static + guarded /uploads/* routes. Must be applied BEFORE the API route mounts
* (identical to its original position near the top of createApp).
*/
export function applyPlatformUploads(app: express.Application): void {
// Static: avatars, covers, and journey photos.
//
// Security model (audit SEC-M9): these paths are unauthenticated by
@@ -76,9 +53,9 @@ export function createApp(): express.Application {
// not embedded in unauthenticated UI contexts, so that endpoint IS
// gated (session JWT with pv, or a share token scoped to the photo's
// trip).
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
app.use('/uploads/avatars', express.static(path.join(UPLOADS_DIR, 'avatars')));
app.use('/uploads/covers', express.static(path.join(UPLOADS_DIR, 'covers')));
app.use('/uploads/journey', express.static(path.join(UPLOADS_DIR, 'journey')));
// Photos require either a valid logged-in session (via JWT with the
// password_version gate) OR a share token that covers the SPECIFIC
@@ -87,9 +64,9 @@ export function createApp(): express.Application {
// unguessable, but the auth model was wrong.
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
const safeName = path.basename(req.params.filename);
const filePath = path.join(__dirname, '../uploads/photos', safeName);
const filePath = path.join(UPLOADS_DIR, 'photos', safeName);
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
if (!resolved.startsWith(path.resolve(UPLOADS_DIR, 'photos'))) {
return res.status(403).send('Forbidden');
}
// existsSync here is cheap and avoids a sendFile error frame; kept
@@ -122,116 +99,23 @@ export function createApp(): express.Application {
app.use('/uploads/files', (_req: Request, res: Response) => {
res.status(401).send('Authentication required');
});
}
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/auth/oidc', oidcRoutes);
app.use('/api/trips', tripsRoutes);
app.use('/api/trips/:tripId/days', daysRoutes);
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/todo', todoRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.get('/api/reservations/upcoming', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ reservations: getUpcomingReservations(authReq.user.id) });
});
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
/**
* Legacy /api/health handler, the OAuth/MCP SDK + transport wiring (well-known metadata,
* authorize/register SDK handlers, the COOP header, the /mcp routes), and the production
* SPA static + catch-all. Must be applied AFTER the API route mounts and BEFORE the global
* error handler (identical to its original position near the bottom of createApp).
*
* Note: the SDK metadata closures (getOAuthMetadata/getMetaRouter) and their lazy-init
* cache are kept module-local PER CALL so each app instance gets its own lazy state the
* same as when they were function-local inside createApp.
*/
export function applyPlatformTransport(app: express.Application): void {
app.get('/api/health', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate')
res.json({ status: 'ok' })
});
app.use('/api/config', publicConfigRoutes);
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/admin', adminRoutes);
// Addons list endpoint
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
const providers = db.prepare(`
SELECT id, name, icon, enabled, sort_order
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id
`).all() as Array<{
provider_id: string;
field_key: string;
label: string;
input_type: string;
placeholder?: string | null;
hint?: string | null;
required: number;
secret: number;
settings_key?: string | null;
payload_key?: string | null;
sort_order: number;
}>;
const fieldsByProvider = new Map<string, typeof fields>();
for (const field of fields) {
const arr = fieldsByProvider.get(field.provider_id) || [];
arr.push(field);
fieldsByProvider.set(field.provider_id, arr);
}
res.json({
collabFeatures: getCollabFeatures(),
addons: [
...addons.map(a => ({ ...a, enabled: !!a.enabled })),
...providers.map(p => ({
id: p.id,
name: p.name,
type: 'photo_provider',
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
placeholder: f.placeholder || '',
hint: f.hint || null,
required: !!f.required,
secret: !!f.secret,
settings_key: f.settings_key || null,
payload_key: f.payload_key || null,
sort_order: f.sort_order,
})),
})),
],
});
});
// Addon routes
app.use('/api/addons/vacay', vacayRoutes);
app.use('/api/addons/atlas', atlasRoutes);
app.use('/api/journeys', (req, res, next) => {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
next();
}, journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes);
// /api/weather is served by the NestJS weather module (see src/nest/weather);
// the legacy Express route was decommissioned after the migration (L1).
app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes);
app.use('/api/backup', backupRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api', shareRoutes);
// OAuth 2.1 — public endpoints
// Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting)
@@ -240,10 +124,6 @@ export function createApp(): express.Application {
next();
};
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
// Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate
app.use('/api/oauth', oauthApiRouter);
// SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB)
// is not called at createApp() time, before test tables have been created.
// mcpAuthMetadataRouter serves:
@@ -326,10 +206,6 @@ export function createApp(): express.Application {
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
// Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth)
// oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here
app.use('/', oauthPublicRouter);
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
@@ -349,39 +225,43 @@ export function createApp(): express.Application {
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
next();
});
}
// Production static file serving
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath, {
/**
* Production SPA serving: the built client static assets + the index.html catch-all
* for client-side routes. This is the LEGACY (plain Express 4) form a real
* `app.get(catch-all)` registered as the terminal handler. The NestJS bootstrap can
* NOT use this (its router terminates unmatched requests with a 404 before any
* post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves
* the SPA via the SpaFallbackFilter instead. Both produce the identical result:
* unmatched GET index.html in production.
*/
export function applyPlatformSpa(app: express.Application): void {
applyPlatformStatic(app);
if (process.env.NODE_ENV !== 'production') return;
// /.*/ rather than '*' so the helper is Express-4 and Express-5 safe.
app.get(/.*/, (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
}
/**
* Production static serving of the built client (JS/CSS/assets). Split out from
* applyPlatformSpa because the NestJS bootstrap needs the static files served
* BEFORE its router (so a real asset request returns the file, not the SPA
* index.html), while the index.html catch-all is handled separately (legacy:
* app.get catch-all; Nest: SpaFallbackFilter). No-op outside production.
*/
export function applyPlatformStatic(app: express.Application): void {
if (process.env.NODE_ENV !== 'production') return;
app.use(
express.static(PUBLIC_DIR, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
}));
app.get('*', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(publicPath, 'index.html'));
});
}
// Global error handler
app.use((err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => {
if (process.env.NODE_ENV === 'production') {
console.error('Unhandled error:', err.message);
} else {
console.error('Unhandled error:', err);
}
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message });
}
const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
});
return app;
}
}),
);
}
@@ -0,0 +1,37 @@
import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
import type { Request, Response } from 'express';
import path from 'node:path';
import { PUBLIC_DIR } from './platform.routes';
/**
* Serves the built SPA (index.html) for any request the NestJS router did not
* match — the production single-page-app fallback. This replaces the legacy
* Express `app.get('*')` catch-all, which cannot run on the Nest instance: Nest's
* router terminates an unmatched request by throwing NotFoundException (it never
* falls through to a post-init Express route), so the SPA fallback has to live
* inside the Nest pipeline as a NotFound filter instead.
*
* Behaviour matches the legacy catch-all exactly: in production, an unmatched GET
* returns index.html; everything else (non-GET, or dev where there is no built
* client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)`
* is more specific than the global TrekExceptionFilter, so Nest routes 404s here
* while every other error still flows through TrekExceptionFilter.
*/
@Catch(NotFoundException)
export class SpaFallbackFilter implements ExceptionFilter {
catch(exception: NotFoundException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const req = ctx.getRequest<Request>();
const res = ctx.getResponse<Response>();
if (process.env.NODE_ENV === 'production' && req.method === 'GET') {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
return;
}
// Non-production, or a non-GET miss: keep the standard TREK 404 envelope
// (identical to what TrekExceptionFilter produces for a NotFoundException).
res.status(404).json({ error: exception.message || 'Not Found' });
}
}
@@ -3,13 +3,15 @@ import { ReservationsController } from './reservations.controller';
import { ReservationsService } from './reservations.service';
import { AccommodationsController } from './accommodations.controller';
import { AccommodationsService } from './accommodations.service';
import { UpcomingReservationsController } from './upcoming-reservations.controller';
/**
* Reservations + accommodations domain (S5 — Phase 2 trip sub-domain).
* Two mounts: /api/trips/:tripId/reservations and /accommodations.
* Mounts: /api/trips/:tripId/reservations, /accommodations, and the cross-trip
* /api/reservations/upcoming dashboard feed.
*/
@Module({
controllers: [ReservationsController, AccommodationsController],
controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController],
providers: [ReservationsService, AccommodationsService],
})
export class ReservationsModule {}
@@ -34,6 +34,12 @@ export class ReservationsService {
return svc.listReservations(tripId);
}
// Cross-trip "upcoming reservations" feed (dashboard widget). Reuses the legacy
// query unchanged; the default limit (6) matches the legacy inline handler.
listUpcoming(userId: number) {
return svc.getUpcomingReservations(userId);
}
create(tripId: string, data: Parameters<typeof svc.createReservation>[1]) {
return svc.createReservation(tripId, data);
}
@@ -0,0 +1,24 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import type { User } from '../../types';
import { ReservationsService } from './reservations.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
/**
* GET /api/reservations/upcoming — the cross-trip "upcoming reservations" feed
* (dashboard widget). Byte-identical to the legacy inline handler in
* server/src/app.ts (authenticate, returns { reservations: [...] }, limit 6).
*
* Separate from the trip-scoped ReservationsController
* (/api/trips/:tripId/reservations) because the base path differs.
*/
@Controller('api/reservations')
@UseGuards(JwtAuthGuard)
export class UpcomingReservationsController {
constructor(private readonly reservations: ReservationsService) {}
@Get('upcoming')
upcoming(@CurrentUser() user: User) {
return { reservations: this.reservations.listUpcoming(user.id) };
}
}
-126
View File
@@ -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));
}
-475
View File
@@ -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;
-19
View File
@@ -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;
-137
View File
@@ -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;
-105
View File
@@ -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;
-413
View File
@@ -1,413 +0,0 @@
import express, { Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuid } from 'uuid';
import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { setAuthCookie, clearAuthCookie } from '../services/cookie';
import {
getAppConfig,
demoLogin,
validateInviteToken,
registerUser,
loginUser,
getCurrentUser,
changePassword,
deleteAccount,
updateMapsKey,
updateApiKeys,
updateSettings,
getSettings,
saveAvatar,
deleteAvatar,
listUsers,
validateKeys,
getAppSettings,
updateAppSettings,
getTravelStats,
setupMfa,
enableMfa,
disableMfa,
verifyMfaLogin,
listMcpTokens,
createMcpToken,
deleteMcpToken,
createWsToken,
createResourceToken,
requestPasswordReset,
resetPassword,
} from '../services/authService';
import { sendPasswordResetEmail, getAppUrl } from '../services/notifications';
const router = express.Router();
// ---------------------------------------------------------------------------
// Avatar upload (multer config stays in route — middleware concern)
// ---------------------------------------------------------------------------
const avatarDir = path.join(__dirname, '../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
const avatarStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, avatarDir),
filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)),
});
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: MAX_AVATAR_SIZE },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed');
err.statusCode = 400;
return cb(err);
}
cb(null, true);
},
});
// ---------------------------------------------------------------------------
// Rate limiter (middleware concern — stays in route)
// ---------------------------------------------------------------------------
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
const RATE_LIMIT_CLEANUP = 5 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number }>();
const mfaAttempts = new Map<string, { count: number; first: number }>();
const forgotAttempts = new Map<string, { count: number; first: number }>();
const resetAttempts = new Map<string, { count: number; first: number }>();
setInterval(() => {
const now = Date.now();
for (const [key, record] of loginAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key);
}
for (const [key, record] of mfaAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key);
}
for (const [key, record] of forgotAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key);
}
for (const [key, record] of resetAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key);
}
}, RATE_LIMIT_CLEANUP);
function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || 'unknown';
const now = Date.now();
const record = store.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
}
if (!record || now - record.first >= windowMs) {
store.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts);
const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts);
const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts);
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
const user = (req as OptionalAuthRequest).user;
res.json(getAppConfig(user));
});
router.post('/demo-login', (req: Request, res: Response) => {
const result = demoLogin();
if (result.error) return res.status(result.status!).json({ error: result.error });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
router.get('/invite/:token', authLimiter, (req: Request, res: Response) => {
const result = validateInviteToken(req.params.token);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at });
});
router.post('/register', authLimiter, (req: Request, res: Response) => {
const result = registerUser(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
setAuthCookie(res, result.token!, req);
res.status(201).json({ token: result.token, user: result.user });
});
router.post('/login', authLimiter, async (req: Request, res: Response) => {
const started = Date.now();
const result = loginUser(req.body);
if (result.auditAction) {
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
}
const elapsed = Date.now() - started;
if (elapsed < LOGIN_MIN_LATENCY_MS) {
await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
}
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
// ---------------------------------------------------------------------------
// Password reset (forgot / complete)
// ---------------------------------------------------------------------------
// Generic OK response — identical regardless of email existence, to
// prevent enumeration via response body OR status code.
const GENERIC_FORGOT_RESPONSE = { ok: true };
// Minimum time we spend inside the forgot/login handlers so a "no such
// user" path does not complete noticeably faster than a real operation.
const FORGOT_MIN_LATENCY_MS = 350;
const LOGIN_MIN_LATENCY_MS = 350;
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
const started = Date.now();
const rawEmail = typeof req.body?.email === 'string' ? req.body.email : '';
const ip = getClientIp(req);
const outcome = requestPasswordReset(rawEmail, ip);
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
// Build the reset URL from the server-side canonical APP_URL (or
// first ALLOWED_ORIGINS entry) — never from request headers. A
// crafted `Origin` / `Host` / `Referer` would otherwise put an
// attacker-controlled domain into the emailed reset link while the
// token itself is still legitimate.
const origin = getAppUrl();
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
// Audit the REQUEST always — even for "no user" — so abuse is visible.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
try {
const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
} catch (err) {
// Never surface delivery failure to the caller — still respond ok.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
}
} else {
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
}
// Pad the response so timing doesn't reveal outcome.
const elapsed = Date.now() - started;
if (elapsed < FORGOT_MIN_LATENCY_MS) {
await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed));
}
res.json(GENERIC_FORGOT_RESPONSE);
});
router.post('/reset-password', resetLimiter, (req: Request, res: Response) => {
const ip = getClientIp(req);
const result = resetPassword(req.body);
if (result.error) {
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
return res.status(result.status!).json({ error: result.error });
}
if (result.mfa_required) {
return res.status(200).json({ mfa_required: true });
}
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
// Purposefully do NOT auto-login — the user just demonstrated they
// have email+password access; asking them to sign in fresh is the
// standard, safer UX.
res.json({ success: true });
});
router.get('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = getCurrentUser(authReq.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ user });
});
router.post('/logout', (req: Request, res: Response) => {
clearAuthCookie(res, req);
res.json({ success: true });
});
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = changePassword(authReq.user.id, authReq.user.email, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) });
res.json({ success: true });
});
router.delete('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = deleteAccount(authReq.user.id, authReq.user.email, authReq.user.role);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) });
res.json({ success: true });
});
router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(updateMapsKey(authReq.user.id, req.body.maps_api_key));
});
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(updateApiKeys(authReq.user.id, req.body));
});
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = updateSettings(authReq.user.id, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ success: result.success, user: result.user });
});
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = getSettings(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ settings: result.settings });
});
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
res.json(await saveAvatar(authReq.user.id, req.file.filename));
});
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(await deleteAvatar(authReq.user.id));
});
router.get('/users', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ users: listUsers(authReq.user.id) });
});
router.get('/validate-keys', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await validateKeys(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ maps: result.maps, weather: result.weather, maps_details: result.maps_details });
});
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = getAppSettings(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json(result.data);
});
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = updateAppSettings(authReq.user.id, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({
userId: authReq.user.id,
action: 'settings.app_update',
ip: getClientIp(req),
details: result.auditSummary,
debugDetails: result.auditDebugDetails,
});
res.json({ success: true });
});
router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(getTravelStats(authReq.user.id));
});
router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
const result = verifyMfaLogin(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = setupMfa(authReq.user.id, authReq.user.email);
if (result.error) return res.status(result.status!).json({ error: result.error });
result.qrPromise!
.then((qr_svg: string) => {
res.json({ secret: result.secret, otpauth_url: result.otpauth_url, qr_svg });
})
.catch((err: unknown) => {
console.error('[MFA] QR code generation error:', err);
res.status(500).json({ error: 'Could not generate QR code' });
});
});
router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = enableMfa(authReq.user.id, req.body.code);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes });
});
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = disableMfa(authReq.user.id, authReq.user.email, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: result.mfa_enabled });
});
// --- MCP Token Management ---
router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ tokens: listMcpTokens(authReq.user.id) });
});
router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createMcpToken(authReq.user.id, req.body.name);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.status(201).json({ token: result.token });
});
router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = deleteMcpToken(authReq.user.id, req.params.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ success: true });
});
// Short-lived single-use token for WebSocket connections
router.post('/ws-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createWsToken(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ token: result.token });
});
// Short-lived single-use token for direct resource URLs
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const token = createResourceToken(authReq.user.id, req.body.purpose);
if (!token) return res.status(503).json({ error: 'Service unavailable' });
res.json(token);
});
export default router;
// Exported for test resets only — do not use in production code
export { loginAttempts, mfaAttempts };
-197
View File
@@ -1,197 +0,0 @@
import express, { Request, Response, NextFunction } from 'express';
import multer from 'multer';
import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import {
listBackups,
createBackup,
restoreFromZip,
getAutoSettings,
updateAutoSettings,
deleteBackup,
isValidBackupFilename,
backupFilePath,
backupFileExists,
checkRateLimit,
getUploadTmpDir,
BACKUP_RATE_WINDOW,
MAX_BACKUP_UPLOAD_SIZE,
} from '../services/backupService';
const router = express.Router();
router.use(authenticate, adminOnly);
// ---------------------------------------------------------------------------
// Rate-limiter middleware (HTTP concern wrapping service-level check)
// ---------------------------------------------------------------------------
function backupRateLimiter(maxAttempts: number, windowMs: number) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || 'unknown';
if (!checkRateLimit(key, maxAttempts, windowMs)) {
return res.status(429).json({ error: 'Too many backup requests. Please try again later.' });
}
next();
};
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
router.get('/list', (_req: Request, res: Response) => {
try {
res.json({ backups: listBackups() });
} catch (err: unknown) {
res.status(500).json({ error: 'Error loading backups' });
}
});
router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (req: Request, res: Response) => {
try {
const backup = await createBackup();
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.create',
resource: backup.filename,
ip: getClientIp(req),
details: { size: backup.size },
});
res.json({ success: true, backup });
} catch (err: unknown) {
res.status(500).json({ error: 'Error creating backup' });
}
});
router.get('/download/:filename', (req: Request, res: Response) => {
const { filename } = req.params;
if (!isValidBackupFilename(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!backupFileExists(filename)) {
return res.status(404).json({ error: 'Backup not found' });
}
res.download(backupFilePath(filename), filename);
});
router.post('/restore/:filename', async (req: Request, res: Response) => {
const { filename } = req.params;
if (!isValidBackupFilename(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const zipPath = backupFilePath(filename);
if (!backupFileExists(filename)) {
return res.status(404).json({ error: 'Backup not found' });
}
try {
const result = await restoreFromZip(zipPath);
if (!result.success) {
return res.status(result.status || 400).json({ error: result.error });
}
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.restore',
resource: filename,
ip: getClientIp(req),
});
res.json({ success: true });
} catch (err: unknown) {
if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' });
}
});
const uploadTmp = multer({
dest: getUploadTmpDir(),
fileFilter: (_req, file, cb) => {
if (file.originalname.endsWith('.zip')) cb(null, true);
else cb(new Error('Only ZIP files allowed'));
},
limits: { fileSize: MAX_BACKUP_UPLOAD_SIZE },
});
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const zipPath = req.file.path;
const origName = req.file.originalname || 'upload.zip';
try {
const result = await restoreFromZip(zipPath);
if (!result.success) {
return res.status(result.status || 400).json({ error: result.error });
}
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.upload_restore',
resource: origName,
ip: getClientIp(req),
});
res.json({ success: true });
} catch (err: unknown) {
if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' });
} finally {
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
}
});
router.get('/auto-settings', (_req: Request, res: Response) => {
try {
const data = getAutoSettings();
res.json(data);
} catch (err: unknown) {
console.error('[backup] GET auto-settings:', err);
res.status(500).json({ error: 'Could not load backup settings' });
}
});
router.put('/auto-settings', (req: Request, res: Response) => {
try {
const settings = updateAutoSettings((req.body || {}) as Record<string, unknown>);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.auto_settings',
ip: getClientIp(req),
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
});
res.json({ settings });
} catch (err: unknown) {
console.error('[backup] PUT auto-settings:', err);
const msg = err instanceof Error ? err.message : String(err);
res.status(500).json({
error: 'Could not save auto-backup settings',
detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined,
});
}
});
router.delete('/:filename', (req: Request, res: Response) => {
const { filename } = req.params;
if (!isValidBackupFilename(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!backupFileExists(filename)) {
return res.status(404).json({ error: 'Backup not found' });
}
deleteBackup(filename);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.delete',
resource: filename,
ip: getClientIp(req),
});
res.json({ success: true });
});
export default router;
-189
View File
@@ -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;
-35
View File
@@ -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;
-328
View File
@@ -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;
-66
View File
@@ -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;
-133
View File
@@ -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 };
-284
View File
@@ -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;
-393
View File
@@ -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;
-56
View File
@@ -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;
-162
View File
@@ -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;
-142
View File
@@ -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;
-150
View File
@@ -1,150 +0,0 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../../middleware/auth';
import { AuthRequest } from '../../types';
import {
getSynologySettings,
updateSynologySettings,
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
streamSynologyAsset,
} from '../../services/memories/synologyService';
import { canAccessUserPhoto, handleServiceResult, fail, success } from '../../services/memories/helpersService';
const router = express.Router();
function _parseStringBodyField(value: unknown): string {
return String(value ?? '').trim();
}
function _parseNumberBodyField(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
router.get('/settings', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologySettings(authReq.user.id));
});
router.put('/settings', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username) {
handleServiceResult(res, fail('URL and username are required', 400));
}
else {
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
}
});
router.get('/status', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologyStatus(authReq.user.id));
});
router.post('/test', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = [];
if (!synology_url) missingFields.push('URL');
if (!synology_username) missingFields.push('Username');
if (!synology_password) missingFields.push('Password');
handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
}
else{
handleServiceResult(res, await testSynologyConnection(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
}
});
router.get('/albums', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
const sid = req.headers['x-socket-id'] as string;
handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId, sid));
});
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
let offset = _parseNumberBodyField(body.offset, 0);
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if (size > 0) limit = size;
if (page > 0) offset = page * limit;
handleServiceResult(res, await searchSynologyPhotos(
authReq.user.id,
from || undefined,
to || undefined,
offset,
limit,
));
});
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, photoId, ownerId } = req.params;
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
}
else {
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
}
});
router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, photoId, ownerId, kind } = req.params;
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
const rawSize = String(req.query.size ?? 'sm');
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
if (kind !== 'thumbnail' && kind !== 'original') {
return handleServiceResult(res, fail('Invalid asset kind', 400));
}
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
}
else{
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase);
}
});
export default router;
-104
View File
@@ -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;
-153
View File
@@ -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;
-416
View File
@@ -1,416 +0,0 @@
import express, { Request, Response } from 'express';
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { isAddonEnabled } from '../services/adminService';
import { ALL_SCOPES } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import {
validateAuthorizeRequest,
createAuthCode,
consumeAuthCode,
saveConsent,
issueTokens,
issueClientCredentialsToken,
refreshTokens,
revokeToken,
verifyPKCE,
authenticateClient,
listOAuthClients,
createOAuthClient,
deleteOAuthClient,
rotateOAuthClientSecret,
listOAuthSessions,
revokeSession,
getUserByAccessToken,
AuthorizeParams,
} from '../services/oauthService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
// ---------------------------------------------------------------------------
// Minimal in-file rate limiter (same pattern as auth.ts)
// ---------------------------------------------------------------------------
interface RateEntry { count: number; first: number; }
function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Request) => string) {
const store = new Map<string, RateEntry>();
setInterval(() => {
const now = Date.now();
for (const [k, r] of store) if (now - r.first >= windowMs) store.delete(k);
}, windowMs).unref();
return (req: Request, res: Response, next: () => void): void => {
const key = keyFn(req);
const now = Date.now();
const record = store.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
if (!record || now - record.first >= windowMs) {
store.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
// ---------------------------------------------------------------------------
// Public router: /oauth/token and /oauth/revoke
// (/.well-known and /oauth/register are now handled by SDK in app.ts)
// ---------------------------------------------------------------------------
export const oauthPublicRouter = express.Router();
// Token endpoint — handles authorization_code and refresh_token grants
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
// M1: RFC 6749 §5.1 — token responses must not be cached
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
// Accept both JSON and application/x-www-form-urlencoded
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
const ip = getClientIp(req);
if (!client_id) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
}
// ---- authorization_code grant ----
if (grant_type === 'authorization_code') {
if (!code || !redirect_uri || !code_verifier) {
return res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
}
const pending = consumeAuthCode(code);
// H5: collapse all invalid_grant cases to one message; log specifics server-side
if (!pending) {
writeAudit({ userId: null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'code_invalid_or_expired' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.clientId !== client_id) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'client_id_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.redirectUri !== redirect_uri) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'redirect_uri_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// RFC 8707: if the auth code was bound to a resource, the token request must present the same value
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'resource_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// Verify client secret
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
// Verify PKCE
if (!verifyPKCE(code_verifier, pending.codeChallenge)) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'pkce_failed' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
const tokens = issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
return res.json(tokens);
}
// ---- refresh_token grant ----
if (grant_type === 'refresh_token') {
if (!refresh_token) {
return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
}
const result = refreshTokens(refresh_token, client_id, client_secret, ip);
if (result.error) {
if (result.error === 'invalid_client') {
logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
}
return res.status(result.status || 400).json({
error: result.error,
error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
});
}
return res.json(result.tokens);
}
// ---- client_credentials grant ----
if (grant_type === 'client_credentials') {
if (!client_secret) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
}
const client = authenticateClient(client_id, client_secret);
if (!client) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
// Public clients and DCR-anonymous clients are ineligible for client_credentials.
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
}
// Scope: use requested subset or fall back to all allowed scopes.
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
let grantedScopes: string[];
if (body.scope) {
const requested = body.scope.split(' ').filter(Boolean);
const invalid = requested.filter(s => !allowedScopes.includes(s));
if (invalid.length > 0) {
return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
}
grantedScopes = requested;
} else {
grantedScopes = allowedScopes;
}
// Audience: honour RFC 8707 resource param; default to the MCP endpoint so the
// token passes audience binding in mcp/index.ts without extra configuration.
const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
return res.json(tokens);
}
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
});
// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3)
// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming.
oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const auth = req.headers['authorization'];
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
return res.status(401).json({ error: 'invalid_token' });
}
const token = auth.slice(7);
const info = getUserByAccessToken(token);
if (!info) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
return res.status(401).json({ error: 'invalid_token' });
}
return res.json({
sub: String(info.user.id),
email: info.user.email,
email_verified: true,
preferred_username: info.user.username,
});
});
// Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { token, client_id, client_secret } = body;
const ip = getClientIp(req);
if (!token || !client_id) {
return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
}
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
revokeToken(token, client_id, undefined, ip);
// RFC 7009 §2.2: always respond 200 even if token was already revoked or not found
return res.status(200).json({});
});
// ---------------------------------------------------------------------------
// API router: /api/oauth/* — authenticated endpoints used by the SPA
// ---------------------------------------------------------------------------
export const oauthApiRouter = express.Router();
// SPA calls this on page load to validate OAuth params before rendering consent UI
oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => {
// M2 / H3: gate by addon; 404 prevents feature fingerprinting for anonymous callers
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const params = req.query as Partial<AuthorizeParams>;
const userId = (req as OptionalAuthRequest).user?.id ?? null;
const result = validateAuthorizeRequest(
{
response_type: params.response_type || '',
client_id: params.client_id || '',
redirect_uri: params.redirect_uri || '',
scope: params.scope || '',
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
resource: typeof params.resource === 'string' ? params.resource : undefined,
},
userId,
);
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
// (validateAuthorizeRequest already does this, but be explicit here)
if (userId === null && result.valid) {
return res.json({ valid: result.valid, loginRequired: true });
}
// For unauthenticated error cases return a generic error to prevent oracle enumeration
if (userId === null && !result.valid) {
return res.json({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' });
}
return res.json(result);
});
// User submits consent (approve or deny) — requires cookie-only auth (M7)
oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Response) => {
const { user } = req as AuthRequest;
const {
client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, approved, resource,
} = req.body as {
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
approved: boolean;
resource?: string;
};
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'MCP is not enabled' });
}
if (!approved) {
// User denied — redirect with error
const url = new URL(redirect_uri);
url.searchParams.set('error', 'access_denied');
url.searchParams.set('error_description', 'User denied the authorization request');
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
}
// Re-validate all params (server-side re-check after user action)
const params: AuthorizeParams = {
response_type: 'code',
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
resource,
};
const validation = validateAuthorizeRequest(params, user.id);
if (!validation.valid) {
return res.status(400).json({ error: validation.error, error_description: validation.error_description });
}
const scopes = validation.scopes!;
// Store consent (union with any existing scopes)
saveConsent(client_id, user.id, scopes, ip);
// Issue auth code
const code = createAuthCode({
clientId: client_id,
userId: user.id,
redirectUri: redirect_uri,
scopes,
resource: validation.resource ?? null,
codeChallenge: code_challenge,
codeChallengeMethod: 'S256',
});
if (!code) {
return res.status(503).json({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' });
}
const url = new URL(redirect_uri);
url.searchParams.set('code', code);
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
});
// ---- OAuth client CRUD ----
oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
return res.json({ clients: listOAuthClients(user.id) });
});
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as {
name: string;
redirect_uris?: string[];
allowed_scopes: string[];
allows_client_credentials?: boolean;
};
const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result);
});
oauthApiRouter.post('/clients/:id/rotate', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = rotateOAuthClientSecret(user.id, req.params.id, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ client_secret: result.client_secret });
});
oauthApiRouter.delete('/clients/:id', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = deleteOAuthClient(user.id, req.params.id, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
// ---- Active OAuth sessions ----
oauthApiRouter.get('/sessions', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
return res.json({ sessions: listOAuthSessions(user.id) });
});
oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
-166
View File
@@ -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;
-271
View File
@@ -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;
-47
View File
@@ -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;
-266
View File
@@ -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;
-10
View File
@@ -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;
-199
View File
@@ -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;
-36
View File
@@ -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;
-61
View File
@@ -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;
-29
View File
@@ -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;
-38
View File
@@ -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;
-127
View File
@@ -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;
-358
View File
@@ -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;
-194
View File
@@ -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;
-4
View File
@@ -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;
}
+6 -1
View File
@@ -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(
+107
View File
@@ -0,0 +1,107 @@
/**
* GET /api/addons e2e — exercises the AddonsController through the real
* JwtAuthGuard against a temp SQLite db. getCollabFeatures + getPhotoProviderConfig
* are mocked; the addons/photo_providers/photo_provider_fields reads run against
* the temp db. Asserts the byte-identical body the legacy inline handler produced.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, type TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT,
label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER,
settings_key TEXT, payload_key TEXT, sort_order INTEGER);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
const { getCollabFeatures, getPhotoProviderConfig } = vi.hoisted(() => ({
getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })),
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
}));
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures }));
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
import { AddonsModule } from '../../src/nest/addons/addons.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [AddonsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run();
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('disabled','Disabled','trip','X',0,2)").run();
db.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES ('immich','Immich','Image',1,1)").run();
db.prepare(`INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
VALUES ('immich','base_url','Base URL','text','https://...',NULL,1,0,'immich_url',NULL,1)`).run();
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app.close();
});
it('401 without a cookie', async () => {
expect((await request(server).get('/api/addons')).status).toBe(401);
});
it('200 returns enabled addons + photo providers (disabled addon excluded)', async () => {
const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
addons: [
{ id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true },
{
id: 'immich',
name: 'Immich',
type: 'photo_provider',
icon: 'Image',
enabled: true,
config: { url: 'https://immich.example' },
fields: [
{
key: 'base_url',
label: 'Base URL',
input_type: 'text',
placeholder: 'https://...',
hint: null,
required: true,
secret: false,
settings_key: 'immich_url',
payload_key: null,
sort_order: 1,
},
],
},
],
});
});
});
+411
View File
@@ -0,0 +1,411 @@
/**
* Memories (photo-providers) module e2e — exercises the migrated
* /api/integrations/memories endpoints (unified + immich + synologyphotos)
* through the real JwtAuthGuard against a temp SQLite db. The provider services
* and canAccessUserPhoto are mocked; fail/success stay real so the envelope
* shapes are produced by the actual helper code.
*
* Focus: auth (401), every route's happy path, the CRITICAL 200-on-failure
* behaviour of /test + /status, and at least one error envelope per provider
* router — all asserted byte-identical to the legacy Express routers.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, canAccessTrip: vi.fn(), closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
// Provider services — fully mocked. fail/success/canAccessUserPhoto from the
// helper module are kept real except canAccessUserPhoto which we override.
const { unified, immich, synology } = vi.hoisted(() => ({
unified: {
listTripPhotos: vi.fn(), addTripPhotos: vi.fn(), setTripPhotoSharing: vi.fn(),
removeTripPhoto: vi.fn(), listTripAlbumLinks: vi.fn(), createTripAlbumLink: vi.fn(), removeAlbumLink: vi.fn(),
},
immich: {
getConnectionSettings: vi.fn(), saveImmichSettings: vi.fn(), setImmichAutoUpload: vi.fn(),
testConnection: vi.fn(), getConnectionStatus: vi.fn(), browseTimeline: vi.fn(), searchPhotos: vi.fn(),
streamImmichAsset: vi.fn(), listAlbums: vi.fn(), getAlbumPhotos: vi.fn(), syncAlbumAssets: vi.fn(),
getAssetInfo: vi.fn(), isValidAssetId: vi.fn(),
},
synology: {
getSynologySettings: vi.fn(), updateSynologySettings: vi.fn(), getSynologyStatus: vi.fn(),
testSynologyConnection: vi.fn(), listSynologyAlbums: vi.fn(), getSynologyAlbumPhotos: vi.fn(),
syncSynologyAlbumLink: vi.fn(), searchSynologyPhotos: vi.fn(), getSynologyAssetInfo: vi.fn(),
streamSynologyAsset: vi.fn(),
},
}));
vi.mock('../../src/services/memories/unifiedService', () => unified);
vi.mock('../../src/services/memories/immichService', () => immich);
vi.mock('../../src/services/memories/synologyService', () => synology);
const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() }));
vi.mock('../../src/services/memories/helpersService', async () => {
const actual = await vi.importActual<typeof import('../../src/services/memories/helpersService')>(
'../../src/services/memories/helpersService',
);
return { ...actual, canAccessUserPhoto };
});
import { MemoriesModule } from '../../src/nest/memories/memories.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const BASE = '/api/integrations/memories';
const UNIFIED = `${BASE}/unified`;
const IMMICH = `${BASE}/immich`;
const SYNO = `${BASE}/synologyphotos`;
describe('Memories e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [MemoriesModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
vi.clearAllMocks();
canAccessUserPhoto.mockReturnValue(true);
immich.isValidAssetId.mockReturnValue(true);
});
// ── Auth ───────────────────────────────────────────────────────────────────
describe('auth', () => {
it('401 without a cookie (unified photos)', async () => {
expect((await request(server).get(`${UNIFIED}/trips/5/photos`)).status).toBe(401);
});
it('401 without a cookie (immich status)', async () => {
expect((await request(server).get(`${IMMICH}/status`)).status).toBe(401);
});
it('401 without a cookie (synology albums)', async () => {
expect((await request(server).get(`${SYNO}/albums`)).status).toBe(401);
});
});
// ── Unified ──────────────────────────────────────────────────────────────────
describe('unified', () => {
it('200 list photos -> { photos }', async () => {
unified.listTripPhotos.mockReturnValue({ success: true, data: [{ photo_id: 1, asset_id: 'a' }] });
const res = await request(server).get(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ photos: [{ photo_id: 1, asset_id: 'a' }] });
});
it('200 add photos -> { success, added } (POST stays 200, not 201)', async () => {
unified.addTripPhotos.mockResolvedValue({ success: true, data: { added: 2, shared: true } });
const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1))
.send({ shared: true, selections: [{ provider: 'immich', asset_ids: ['a', 'b'] }] });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, added: 2 });
// x-socket-id absent -> undefined, matching the legacy `req.headers['x-socket-id'] as string`.
expect(unified.addTripPhotos).toHaveBeenCalledWith('5', 1, true, [{ provider: 'immich', asset_ids: ['a', 'b'] }], undefined);
});
it('400 add photos with empty selections -> error envelope', async () => {
unified.addTripPhotos.mockResolvedValue({ success: false, error: { message: 'No photos selected', status: 400 } });
const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ selections: [] });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'No photos selected' });
});
it('200 PUT sharing -> { success: true }', async () => {
unified.setTripPhotoSharing.mockResolvedValue({ success: true, data: true });
const res = await request(server).put(`${UNIFIED}/trips/5/photos/sharing`).set('Cookie', sessionCookie(1)).send({ photo_id: 9, shared: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
it('404 DELETE photo on inaccessible trip -> error envelope', async () => {
unified.removeTripPhoto.mockReturnValue({ success: false, error: { message: 'Trip not found or access denied', status: 404 } });
const res = await request(server).delete(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ photo_id: 9 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found or access denied' });
});
it('200 list album-links -> { links }', async () => {
unified.listTripAlbumLinks.mockReturnValue({ success: true, data: [{ id: 'l1' }] });
const res = await request(server).get(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ links: [{ id: 'l1' }] });
});
it('200 create album-link / 409 duplicate envelope', async () => {
unified.createTripAlbumLink.mockReturnValue({ success: true, data: true });
const ok = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' });
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ success: true });
unified.createTripAlbumLink.mockReturnValue({ success: false, error: { message: 'Album already linked', status: 409 } });
const dup = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' });
expect(dup.status).toBe(409);
expect(dup.body).toEqual({ error: 'Album already linked' });
});
it('200 DELETE album-link -> { success: true }', async () => {
unified.removeAlbumLink.mockReturnValue({ success: true, data: true });
const res = await request(server).delete(`${UNIFIED}/trips/5/album-links/7`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
});
// ── Immich ───────────────────────────────────────────────────────────────────
describe('immich', () => {
it('200 settings', async () => {
immich.getConnectionSettings.mockReturnValue({ immich_url: '', connected: false, auto_upload: false });
const res = await request(server).get(`${IMMICH}/settings`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ immich_url: '', connected: false, auto_upload: false });
});
it('200 PUT settings success / 400 invalid url', async () => {
immich.saveImmichSettings.mockResolvedValue({ success: true });
const ok = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', auto_upload: true });
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ success: true });
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(1, true);
immich.saveImmichSettings.mockResolvedValue({ success: false, error: 'Invalid Immich URL: bad' });
const bad = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'bad' });
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'Invalid Immich URL: bad' });
});
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
immich.getConnectionStatus.mockResolvedValue({ connected: false, error: 'Not configured' });
const res = await request(server).get(`${IMMICH}/status`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'Not configured' });
});
it('CRITICAL: 200 /test missing fields -> { connected: false, error } without calling service', async () => {
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'URL and API key required' });
expect(immich.testConnection).not.toHaveBeenCalled();
});
it('200 /test with creds delegates to service', async () => {
immich.testConnection.mockResolvedValue({ connected: true, user: { name: 'T' } });
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: true, user: { name: 'T' } });
});
it('200 browse / 400 not configured', async () => {
immich.browseTimeline.mockResolvedValue({ buckets: [{ count: 3 }] });
const ok = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ buckets: [{ count: 3 }] });
immich.browseTimeline.mockResolvedValue({ error: 'Immich not configured', status: 400 });
const bad = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1));
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'Immich not configured' });
});
it('200 search (POST stays 200) / 502 envelope', async () => {
immich.searchPhotos.mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true });
const ok = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({ page: 1, size: 50 });
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ assets: [{ id: 'a' }], hasMore: true });
expect(immich.searchPhotos).toHaveBeenCalledWith(1, undefined, undefined, 1, 50);
immich.searchPhotos.mockResolvedValue({ error: 'Could not reach Immich', status: 502 });
const bad = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({});
expect(bad.status).toBe(502);
expect(bad.body).toEqual({ error: 'Could not reach Immich' });
});
it('200 asset info / 400 invalid id / 403 no access', async () => {
immich.getAssetInfo.mockResolvedValue({ data: { id: 'asset-1', city: 'Paris' } });
const ok = await request(server).get(`${IMMICH}/assets/5/asset-1/1/info`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ id: 'asset-1', city: 'Paris' });
immich.isValidAssetId.mockReturnValue(false);
const invalid = await request(server).get(`${IMMICH}/assets/5/bad/1/info`).set('Cookie', sessionCookie(1));
expect(invalid.status).toBe(400);
expect(invalid.body).toEqual({ error: 'Invalid asset ID' });
immich.isValidAssetId.mockReturnValue(true);
canAccessUserPhoto.mockReturnValue(false);
const forbidden = await request(server).get(`${IMMICH}/assets/5/asset-1/2/info`).set('Cookie', sessionCookie(1));
expect(forbidden.status).toBe(403);
expect(forbidden.body).toEqual({ error: 'Forbidden' });
});
it('streams thumbnail bytes via the service helper', async () => {
immich.streamImmichAsset.mockImplementation(async (res: any) => {
res.status(200);
res.set('Content-Type', 'image/webp');
res.end(Buffer.from('thumb-bytes'));
});
const res = await request(server).get(`${IMMICH}/assets/5/asset-1/1/thumbnail`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/webp');
expect(immich.streamImmichAsset).toHaveBeenCalledWith(expect.anything(), 1, 'asset-1', 'thumbnail', 1);
});
it('200 albums / 200 album photos', async () => {
immich.listAlbums.mockResolvedValue({ albums: [{ id: 'al' }] });
const albums = await request(server).get(`${IMMICH}/albums`).set('Cookie', sessionCookie(1));
expect(albums.status).toBe(200);
expect(albums.body).toEqual({ albums: [{ id: 'al' }] });
immich.getAlbumPhotos.mockResolvedValue({ assets: [{ id: 'p1' }] });
const photos = await request(server).get(`${IMMICH}/albums/al/photos`).set('Cookie', sessionCookie(1));
expect(photos.status).toBe(200);
expect(photos.body).toEqual({ assets: [{ id: 'p1' }] });
});
it('200 album sync (POST stays 200) / 404 envelope', async () => {
immich.syncAlbumAssets.mockResolvedValue({ success: true, added: 3, total: 10 });
const ok = await request(server).post(`${IMMICH}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ success: true, added: 3, total: 10 });
immich.syncAlbumAssets.mockResolvedValue({ error: 'Album link not found', status: 404 });
const bad = await request(server).post(`${IMMICH}/trips/5/album-links/9/sync`).set('Cookie', sessionCookie(1));
expect(bad.status).toBe(404);
expect(bad.body).toEqual({ error: 'Album link not found' });
});
});
// ── Synology ───────────────────────────────────────────────────────────────
describe('synologyphotos', () => {
it('200 settings', async () => {
synology.getSynologySettings.mockResolvedValue({ success: true, data: { synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true } });
const res = await request(server).get(`${SYNO}/settings`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true });
});
it('400 PUT settings without url/username -> envelope', async () => {
const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: '' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'URL and username are required' });
expect(synology.updateSynologySettings).not.toHaveBeenCalled();
});
it('200 PUT settings delegates when valid', async () => {
synology.updateSynologySettings.mockResolvedValue({ success: true, data: 'settings updated' });
const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' });
expect(res.status).toBe(200);
expect(res.body).toEqual('settings updated');
});
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
synology.getSynologyStatus.mockResolvedValue({ success: true, data: { connected: false, error: 'Synology not configured' } });
const res = await request(server).get(`${SYNO}/status`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'Synology not configured' });
});
it('CRITICAL: 200 /test missing fields -> 200 { connected: false, error } without calling service', async () => {
const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'Username, Password are required' });
expect(synology.testSynologyConnection).not.toHaveBeenCalled();
});
it('200 /test delegates when all fields present', async () => {
synology.testSynologyConnection.mockResolvedValue({ success: true, data: { connected: true, user: { name: 'admin' } } });
const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: true, user: { name: 'admin' } });
});
it('200 albums / 200 album photos with passphrase', async () => {
synology.listSynologyAlbums.mockResolvedValue({ success: true, data: { albums: [{ id: '1', albumName: 'A', assetCount: 3 }] } });
const albums = await request(server).get(`${SYNO}/albums`).set('Cookie', sessionCookie(1));
expect(albums.status).toBe(200);
expect(albums.body).toEqual({ albums: [{ id: '1', albumName: 'A', assetCount: 3 }] });
synology.getSynologyAlbumPhotos.mockResolvedValue({ success: true, data: { assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false } });
const photos = await request(server).get(`${SYNO}/albums/1/photos?passphrase=secret`).set('Cookie', sessionCookie(1));
expect(photos.status).toBe(200);
expect(photos.body).toEqual({ assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false });
expect(synology.getSynologyAlbumPhotos).toHaveBeenCalledWith(1, '1', 'secret');
});
it('200 search (POST stays 200) with offset/limit coercion', async () => {
synology.searchSynologyPhotos.mockResolvedValue({ success: true, data: { assets: [], total: 0, hasMore: false } });
const res = await request(server).post(`${SYNO}/search`).set('Cookie', sessionCookie(1)).send({ page: 3, size: 20 });
expect(res.status).toBe(200);
expect(res.body).toEqual({ assets: [], total: 0, hasMore: false });
// page=3 -> (3-1)=2; size=20 -> limit=20; offset = 2 * 20 = 40
expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(1, undefined, undefined, 40, 20);
});
it('200 album sync (POST stays 200)', async () => {
synology.syncSynologyAlbumLink.mockResolvedValue({ success: true, data: { added: 2, total: 5 } });
const res = await request(server).post(`${SYNO}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ added: 2, total: 5 });
});
it('200 asset info / 403 distinct synology string on no access', async () => {
synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: '40808_1', takenAt: null } });
const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/info`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ id: '40808_1', takenAt: null });
canAccessUserPhoto.mockReturnValue(false);
const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/info`).set('Cookie', sessionCookie(1));
expect(forbidden.status).toBe(403);
expect(forbidden.body).toEqual({ error: "You don't have access to this photo" });
});
it('400 invalid asset kind / 403 no access / stream on valid kind', async () => {
const invalid = await request(server).get(`${SYNO}/assets/5/40808_1/1/bogus`).set('Cookie', sessionCookie(1));
expect(invalid.status).toBe(400);
expect(invalid.body).toEqual({ error: 'Invalid asset kind' });
canAccessUserPhoto.mockReturnValue(false);
const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/thumbnail`).set('Cookie', sessionCookie(1));
expect(forbidden.status).toBe(403);
expect(forbidden.body).toEqual({ error: "You don't have access to this photo" });
canAccessUserPhoto.mockReturnValue(true);
synology.streamSynologyAsset.mockImplementation(async (res: any) => {
res.status(200);
res.set('Content-Type', 'image/jpeg');
res.end(Buffer.from('syno-bytes'));
});
const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/thumbnail?size=xl`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.headers['content-type']).toContain('image/jpeg');
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(expect.anything(), 1, 1, '40808_1', 'thumbnail', 'xl', undefined);
});
});
});
+12 -1
View File
@@ -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));
+19
View File
@@ -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',
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -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();
});
+122
View File
@@ -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;
}
});
});
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -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();
});
+18 -7
View File
@@ -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)
+13 -8
View File
@@ -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 });
});
+13 -8
View File
@@ -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();
});
+17 -8
View File
@@ -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)
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -38,33 +39,37 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { createMcpToken } from '../helpers/factories';
import { closeMcpSessions } from '../../src/mcp/index';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
closeMcpSessions();
await nestApp.close();
testDb.close();
});
@@ -9,6 +9,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
@@ -36,8 +37,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// ── SSRF guard mock — routes all Immich API calls to fake responses ───────────
vi.mock('../../src/utils/ssrfGuard', async () => {
@@ -164,31 +166,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { safeFetch } from '../../src/utils/ssrfGuard';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const IMMICH = '/api/integrations/memories/immich';
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => testDb.close());
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ── Connection status ─────────────────────────────────────────────────────────
@@ -11,6 +11,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
@@ -38,6 +39,7 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
@@ -190,31 +192,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { safeFetch } from '../../src/utils/ssrfGuard';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const SYNO = '/api/integrations/memories/synologyphotos';
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => testDb.close());
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ── Settings ──────────────────────────────────────────────────────────────────
@@ -9,6 +9,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
@@ -36,8 +37,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/utils/ssrfGuard', async () => {
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
return {
@@ -47,30 +49,34 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const BASE = '/api/integrations/memories/unified';
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => testDb.close());
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ── Helpers ──────────────────────────────────────────────────────────────────
+30 -22
View File
@@ -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 () => {
+13 -9
View File
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -38,8 +39,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/services/notifications', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
return {
@@ -49,28 +51,30 @@ vi.mock('../../src/services/notifications', async (importOriginal) => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+34 -18
View File
@@ -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' });
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import path from 'path';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -41,7 +42,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/services/placeService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/placeService')>();
return {
@@ -51,36 +54,38 @@ vi.mock('../../src/services/placeService', async (importOriginal) => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as placeService from '../../src/services/placeService';
import { invalidatePermissionsCache } from '../../src/services/permissions';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml');
const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml');
const KML_MALFORMED_FIXTURE = path.join(__dirname, '../fixtures/test-malformed.kml');
const KMZ_FIXTURE = path.join(__dirname, '../fixtures/test.kmz');
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
invalidatePermissionsCache();
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -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();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});

Some files were not shown because too many files have changed in this diff Show More