Compare commits

...

21 Commits

Author SHA1 Message Date
github-actions[bot] f2ffea5ba4 chore: bump version to 2.9.8 [skip ci] 2026-04-05 22:09:41 +00:00
jubnl b0dee4dafb feat(mcp): add MCP_MAX_SESSION_PER_USER env var and document it everywhere 2026-04-06 00:09:22 +02:00
github-actions[bot] beb48af8ed chore: bump version to 2.9.7 [skip ci] 2026-04-05 21:38:56 +00:00
jubnl e2be3ec191 fix(atlas): replace fuzzy region matching with exact name_en check
Bidirectional substring matching in isVisitedFeature caused unrelated
regions to be highlighted as visited (e.g. selecting Nordrhein-Westfalen
also marked Nord France due to "nord" being a substring match).

Replace the fuzzy loop with an additional exact check against the Natural
Earth name_en property to cover English-vs-native name mismatches.
Also fix Nominatim field priority to prefer state over county so
reverse-geocoded places resolve to the correct admin-1 level.

Adds integration tests ATLAS-009 through ATLAS-011 covering mark/unmark
region endpoints and user isolation.

Fixes #446
2026-04-05 23:38:34 +02:00
github-actions[bot] 68a1f9683e chore: bump version to 2.9.6 [skip ci] 2026-04-05 21:26:44 +00:00
Maurice 5c57116a68 fix(dayplan): restore time-based auto-sort for places and free reorder for untimed
Timed places now auto-sort chronologically when a time is set.
Untimed places can be freely dragged between timed items.
Transports are inserted by time with per-day position override.
Fixes regression from multi-day spanning PR that removed timed/untimed split.
2026-04-05 23:26:35 +02:00
github-actions[bot] 48508b9df4 chore: bump version to 2.9.5 [skip ci] 2026-04-05 21:12:19 +00:00
jubnl c8250256a7 fix(streaming): end response on client disconnect during asset pipe
When a client disconnects mid-stream, headers are already sent so the
catch block now calls response.end() before returning, preventing the
socket from being left open and crashing the server. Fixes #445.
2026-04-05 23:11:57 +02:00
github-actions[bot] 6491e1f986 chore: bump version to 2.9.4 [skip ci] 2026-04-05 21:02:53 +00:00
Maurice 03757ed0af fix(dayplan): per-day transport positions for multi-day reservations
Reordering places on one day of a multi-day reservation no longer
affects the order on other days. Transport positions are now stored
per-day in a new reservation_day_positions table instead of a single
global day_plan_position on the reservation.
2026-04-05 23:02:42 +02:00
github-actions[bot] a676dbe881 chore: bump version to 2.9.3 [skip ci] 2026-04-05 20:46:34 +00:00
jubnl 411d8620ba fix(reservations): reset stale budget category when it no longer exists
If the budget category stored in reservation metadata was deleted, the
form would re-submit it on next save, resurrecting the deleted category.
Now validates against live budget items on form init and falls back to
auto-generation when the stored category is gone.

Closes #442
2026-04-05 22:46:16 +02:00
github-actions[bot] f45f56318a chore: bump version to 2.9.2 [skip ci] 2026-04-05 20:36:00 +00:00
jubnl 3ae0f3f819 Merge remote-tracking branch 'origin/main' 2026-04-05 22:35:41 +02:00
jubnl 306626ee1c fix(trip): redirect to plan tab when active tab's addon is disabled
If a user's last visited tab belongs to an addon that gets disabled while
they are away, re-opening the trip now resets the active tab to 'plan'
instead of rendering the inaccessible addon page.

Closes #441
2026-04-05 22:30:22 +02:00
jubnl 7e0fe3b1b9 fix(reservations): hide price/budget fields when budget addon is disabled
Closes #440
2026-04-05 22:30:13 +02:00
jubnl fdbc015dbf fix(memories): re-fetch EXIF info when navigating between lightbox photos
The navigateTo function was clearing lightboxInfo without re-fetching it,
causing the EXIF sidebar to disappear and nav button placement to break.
Mirrors the fetch logic already present in the thumbnail click handler.

Fixes #439
2026-04-05 22:30:05 +02:00
github-actions[bot] 7d8e3912b4 chore: bump version to 2.9.1 [skip ci] 2026-04-05 20:20:56 +00:00
jubnl 9ebca725ae fix(CSP): Paths that end in / match any path they are a prefix of. 2026-04-05 22:20:40 +02:00
github-actions[bot] 9718187490 chore: bump version to 2.9.0 [skip ci] 2026-04-05 19:38:21 +00:00
Julien G. aa0620e01f Merge pull request #421 from mauriceboe/dev
v2.9.0
2026-04-05 21:38:11 +02:00
24 changed files with 381 additions and 73 deletions
+2
View File
@@ -161,6 +161,7 @@ services:
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist # - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) # - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
@@ -303,6 +304,7 @@ trek.yourdomain.com {
| **Other** | | | | **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` |
## Optional API Keys ## Optional API Keys
+2
View File
@@ -53,6 +53,8 @@ env:
# Enable demo mode (hourly data resets). # Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "60" # MCP_RATE_LIMIT: "60"
# Max MCP API requests per user per minute. Defaults to 60. # Max MCP API requests per user per minute. Defaults to 60.
# MCP_MAX_SESSION_PER_USER: "5"
# Max concurrent MCP sessions per user. Defaults to 5.
# Secret environment variables stored in a Kubernetes Secret. # Secret environment variables stored in a Kubernetes Secret.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.8.4", "version": "2.9.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "2.8.4", "version": "2.9.8",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.8.4", "version": "2.9.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -248,7 +248,7 @@ export const reservationsApi = {
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
} }
export const weatherApi = { export const weatherApi = {
@@ -956,6 +956,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setLightboxUserId(photo.user_id) setLightboxUserId(photo.user_id)
setLightboxInfo(null) setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
setLightboxInfoLoading(true)
apiClient.get(buildProviderAssetUrl(photo, 'info'))
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
} }
const exifContent = lightboxInfo ? ( const exifContent = lightboxInfo ? (
@@ -341,14 +341,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
initTransportPositions(dayId) initTransportPositions(dayId)
} }
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order // All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
// Places keep their order_index ordering — only transports are inserted based on time.
const baseItems = [ const baseItems = [
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })), ...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })), ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey) ].sort((a, b) => a.sortKey - b.sortKey)
// Only transports are inserted among base items based on time/position // Transports are inserted among places based on time
const timedTransports = transport.map(r => ({ const timedTransports = transport.map(r => ({
type: 'transport' as const, type: 'transport' as const,
data: r, data: r,
@@ -360,19 +359,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return timedTransports.map((item, i) => ({ ...item, sortKey: i })) return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
} }
// Insert transports among base items using persisted position or time-to-position mapping. // Insert transports among places based on per-day position or time
const result = [...baseItems] const result = [...baseItems]
for (let ti = 0; ti < timedTransports.length; ti++) { for (let ti = 0; ti < timedTransports.length; ti++) {
const timed = timedTransports[ti] const timed = timedTransports[ti]
const minutes = timed.minutes const minutes = timed.minutes
// Use persisted position if available // Use per-day position if explicitly set by user reorder
if (timed.data.day_plan_position != null) { const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data }) if (perDayPos != null) {
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
continue continue
} }
// Find insertion position: after the last base item with time <= this transport's time // Find insertion position: after the last place with time <= this transport's time
let insertAfterKey = -Infinity let insertAfterKey = -Infinity
for (const item of result) { for (const item of result) {
if (item.type === 'place') { if (item.type === 'place') {
@@ -500,10 +500,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (transportUpdates.length) { if (transportUpdates.length) {
for (const tu of transportUpdates) { for (const tu of transportUpdates) {
const res = reservations.find(r => r.id === tu.id) const res = reservations.find(r => r.id === tu.id)
if (res) res.day_plan_position = tu.day_plan_position if (res) {
res.day_plan_position = tu.day_plan_position
// Update per-day position for multi-day reservations
if (!res.day_positions) res.day_positions = {}
res.day_positions[dayId] = tu.day_plan_position
}
} }
setTransportPosVersion(v => v + 1) setTransportPosVersion(v => v + 1)
await reservationsApi.updatePositions(tripId, transportUpdates) await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
} }
if (prevAssignmentIds.length) { if (prevAssignmentIds.length) {
const capturedDayId = dayId const capturedDayId = dayId
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
@@ -71,6 +72,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems) const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => { const budgetCategories = useMemo(() => {
const cats = new Set<string>() const cats = new Set<string>()
@@ -139,7 +141,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(), hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(), hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '', price: meta.price || '',
budget_category: meta.budget_category || '', budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
} else { } else {
setForm({ setForm({
@@ -196,8 +198,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.end_date) { if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
} }
if (form.price) metadata.price = form.price if (isBudgetEnabled) {
if (form.budget_category) metadata.budget_category = form.budget_category if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> = { const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status, title: form.title, type: form.type, status: form.status,
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime, reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
@@ -208,9 +212,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
metadata: Object.keys(metadata).length > 0 ? metadata : null, metadata: Object.keys(metadata).length > 0 ? metadata : null,
} }
// Auto-create/update budget entry if price is set, or signal removal if cleared // Auto-create/update budget entry if price is set, or signal removal if cleared
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0 if (isBudgetEnabled) {
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' } saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
: { total_price: 0 } ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
// If hotel with place + days, pass hotel data for auto-creation or update // If hotel with place + days, pass hotel data for auto-creation or update
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) { if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = { saveData.create_accommodation = {
@@ -643,33 +649,37 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div> </div>
</div> </div>
{/* Price + Budget Category */} {/* Price + Budget Category — only shown when budget addon is enabled */}
<div style={{ display: 'flex', gap: 8 }}> {isBudgetEnabled && (
<div style={{ flex: 1, minWidth: 0 }}> <>
<label style={labelStyle}>{t('reservations.price')}</label> <div style={{ display: 'flex', gap: 8 }}>
<input type="text" inputMode="decimal" value={form.price} <div style={{ flex: 1, minWidth: 0 }}>
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }} <label style={labelStyle}>{t('reservations.price')}</label>
placeholder="0.00" <input type="text" inputMode="decimal" value={form.price}
style={inputStyle} /> onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
</div> placeholder="0.00"
<div style={{ flex: 1, minWidth: 0 }}> style={inputStyle} />
<label style={labelStyle}>{t('reservations.budgetCategory')}</label> </div>
<CustomSelect <div style={{ flex: 1, minWidth: 0 }}>
value={form.budget_category} <label style={labelStyle}>{t('reservations.budgetCategory')}</label>
onChange={v => set('budget_category', v)} <CustomSelect
options={[ value={form.budget_category}
{ value: '', label: t('reservations.budgetCategoryAuto') }, onChange={v => set('budget_category', v)}
...budgetCategories.map(c => ({ value: c, label: c })), options={[
]} { value: '', label: t('reservations.budgetCategoryAuto') },
placeholder={t('reservations.budgetCategoryAuto')} ...budgetCategories.map(c => ({ value: c, label: c })),
size="sm" ]}
/> placeholder={t('reservations.budgetCategoryAuto')}
</div> size="sm"
</div> />
{form.price && parseFloat(form.price) > 0 && ( </div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}> </div>
{t('reservations.budgetHint')} {form.price && parseFloat(form.price) > 0 && (
</div> <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)} )}
{/* Actions */} {/* Actions */}
+6 -7
View File
@@ -480,15 +480,13 @@ export default function AtlasPage(): React.ReactElement {
} }
} }
// Match feature by ISO code OR region name // Match feature by ISO code OR region name (native or English)
const isVisitedFeature = (f: any) => { const isVisitedFeature = (f: any) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const name = (f.properties?.name || '').toLowerCase() const name = (f.properties?.name || '').toLowerCase()
if (visitedRegionNames.has(name)) return true if (visitedRegionNames.has(name)) return true
// Fuzzy: check if any visited name is contained in feature name or vice versa const nameEn = (f.properties?.name_en || '').toLowerCase()
for (const vn of visitedRegionNames) { if (nameEn && visitedRegionNames.has(nameEn)) return true
if (name.includes(vn) || vn.includes(name)) return true
}
return false return false
} }
@@ -535,15 +533,16 @@ export default function AtlasPage(): React.ReactElement {
}, },
onEachFeature: (feature, layer) => { onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || '' const regionName = feature?.properties?.name || ''
const regionNameEn = feature?.properties?.name_en || ''
const countryName = feature?.properties?.admin || '' const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || '' const regionCode = feature?.properties?.iso_3166_2 || ''
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature) const visited = isVisitedFeature(feature)
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0 const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || regionPlaceCounts[regionNameEn.toLowerCase()] || 0
layer.on('click', () => { layer.on('click', () => {
if (!countryA2) return if (!countryA2) return
if (visited) { if (visited) {
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode) const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
if (regionEntry?.manuallyMarked) { if (regionEntry?.manuallyMarked) {
setConfirmActionRef.current({ setConfirmActionRef.current({
type: 'unmark-region', type: 'unmark-region',
+8
View File
@@ -137,6 +137,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
return saved || 'plan' return saved || 'plan'
}) })
useEffect(() => {
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [enabledAddons])
const handleTabChange = (tabId: string): void => { const handleTabChange = (tabId: string): void => {
setActiveTab(tabId) setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId) sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
+1
View File
@@ -39,6 +39,7 @@ services:
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist # - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) # - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
+1
View File
@@ -29,6 +29,7 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
DEMO_MODE=false # Demo mode - resets data hourly DEMO_MODE=false # Demo mode - resets data hourly
# MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) # MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
# Initial admin account — only used on first boot when no users exist yet. # Initial admin account — only used on first boot when no users exist yet.
# If both are set the admin account is created with these credentials. # If both are set the admin account is created with these credentials.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.8.4", "version": "2.9.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "2.8.4", "version": "2.9.8",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.8.4", "version": "2.9.8",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
+1 -1
View File
@@ -84,7 +84,7 @@ export function createApp(): express.Application {
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com", "https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson", "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
"https://router.project-osrm.org/route/v1" "https://router.project-osrm.org/route/v1/"
], ],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"], objectSrc: ["'none'"],
+21
View File
@@ -843,6 +843,27 @@ function runMigrations(db: Database.Database): void {
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const b of bagsWithUser) ins.run(b.id, b.user_id); for (const b of bagsWithUser) ins.run(b.id, b.user_id);
}, },
// Migration: Per-day positions for multi-day reservations
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_day_positions (
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
position REAL NOT NULL,
PRIMARY KEY (reservation_id, day_id)
);
`);
// Migrate existing global positions to per-day entries
const reservations = db.prepare('SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL').all() as any[];
const ins = db.prepare('INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
for (const r of reservations) {
const startDate = r.reservation_time?.split('T')[0];
const endDate = r.reservation_end_time?.split('T')[0] || startDate;
if (!startDate) continue;
const matchingDays = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?').all(r.trip_id, startDate, endDate) as { id: number }[];
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+2 -1
View File
@@ -18,7 +18,8 @@ interface McpSession {
const sessions = new Map<string, McpSession>(); const sessions = new Map<string, McpSession>();
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_SESSIONS_PER_USER = 5; const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 5;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? ""); const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
+3 -2
View File
@@ -91,10 +91,11 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' }); if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
updatePositions(tripId, positions); const { day_id } = req.body;
updatePositions(tripId, positions, day_id);
res.json({ success: true }); res.json({ success: true });
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string); broadcast(tripId, 'reservation:positions', { positions, day_id }, req.headers['x-socket-id'] as string);
}); });
router.put('/:id', authenticate, (req: Request, res: Response) => { router.put('/:id', authenticate, (req: Request, res: Response) => {
+28
View File
@@ -168,6 +168,34 @@ export function getParticipants(assignmentId: string | number) {
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) { export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?') db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run(placeTime ?? null, endTime ?? null, id); .run(placeTime ?? null, endTime ?? null, id);
// Auto-sort: reorder timed assignments chronologically within the day
if (placeTime) {
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as { day_id: number } | undefined;
if (assignment) {
const dayAssignments = db.prepare(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
FROM day_assignments da
JOIN places p ON da.place_id = p.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC
`).all(assignment.day_id) as { id: number; effective_time: string | null }[];
// Separate timed and untimed, sort timed by time
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
return ta.localeCompare(tb);
});
const untimed = dayAssignments.filter(a => !a.effective_time);
// Interleave: timed in chronological order, untimed keep relative position
const reordered = [...timed, ...untimed];
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
reordered.forEach((a, i) => update.run(i, a.id));
}
}
return getAssignmentWithPlace(Number(id)); return getAssignmentWithPlace(Number(id));
} }
+1 -1
View File
@@ -421,7 +421,7 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInf
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) { if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
regionCode = regionCode.replace(/[A-Z]$/i, ''); regionCode = regionCode.replace(/[A-Z]$/i, '');
} }
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null; const regionName = data.address?.state || data.address?.province || data.address?.region || data.address?.county || data.address?.city || null;
if (!countryCode || !regionName) { regionCache.set(key, null); return null; } if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
const info: RegionInfo = { const info: RegionInfo = {
country_code: countryCode, country_code: countryCode,
@@ -178,7 +178,10 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
await pipeline(Readable.fromWeb(resp.body as any), response); await pipeline(Readable.fromWeb(resp.body as any), response);
} }
} catch (error) { } catch (error) {
if (response.headersSent) return; if (response.headersSent) {
response.end();
return;
}
if (error instanceof SsrfBlockedError) { if (error instanceof SsrfBlockedError) {
response.status(400).json({ error: error.message }); response.status(400).json({ error: error.message });
} else { } else {
+51 -10
View File
@@ -6,7 +6,7 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
} }
export function listReservations(tripId: string | number) { export function listReservations(tripId: string | number) {
return db.prepare(` const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r FROM reservations r
@@ -16,7 +16,27 @@ export function listReservations(tripId: string | number) {
LEFT JOIN places acc_p ON ap.place_id = acc_p.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.trip_id = ? WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId); `).all(tripId) as any[];
// Attach per-day positions for multi-day reservations
const dayPositions = db.prepare(`
SELECT rdp.reservation_id, rdp.day_id, rdp.position
FROM reservation_day_positions rdp
JOIN reservations r ON rdp.reservation_id = r.id
WHERE r.trip_id = ?
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
const posMap = new Map<number, Record<number, number>>();
for (const dp of dayPositions) {
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
}
for (const r of reservations) {
r.day_positions = posMap.get(r.id) || null;
}
return reservations;
} }
export function getReservationWithJoins(id: string | number) { export function getReservationWithJoins(id: string | number) {
@@ -117,14 +137,35 @@ export function createReservation(tripId: string | number, data: CreateReservati
return { reservation, accommodationCreated }; return { reservation, accommodationCreated };
} }
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[]) { export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[], dayId?: number | string) {
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?'); if (dayId) {
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => { // Per-day positions for multi-day reservations
for (const item of items) { const stmt = db.prepare('INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
stmt.run(item.day_plan_position, item.id, tripId); const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
} for (const item of items) {
}); stmt.run(item.id, dayId, item.day_plan_position);
updateMany(positions); }
});
updateMany(positions);
} else {
// Legacy: update global position
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
for (const item of items) {
stmt.run(item.day_plan_position, item.id, tripId);
}
});
updateMany(positions);
}
}
export function getDayPositions(tripId: string | number, dayId: number | string) {
return db.prepare(`
SELECT rdp.reservation_id, rdp.position
FROM reservation_day_positions rdp
JOIN reservations r ON rdp.reservation_id = r.id
WHERE r.trip_id = ? AND rdp.day_id = ?
`).all(tripId, dayId) as { reservation_id: number; position: number }[];
} }
export function getReservation(id: string | number, tripId: string | number) { export function getReservation(id: string | number, tripId: string | number) {
+181
View File
@@ -202,3 +202,184 @@ describe('Bucket list', () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
describe('Mark/unmark region', () => {
it('ATLAS-009 — POST /region/:code/mark marks a region as visited', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('ATLAS-009 — POST /region/:code/mark without name returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ country_code: 'DE' });
expect(res.status).toBe(400);
});
it('ATLAS-009 — POST /region/:code/mark without country_code returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen' });
expect(res.status).toBe(400);
});
it('ATLAS-009 — marking a region also auto-marks the parent country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).toContain('DE');
});
it('ATLAS-009 — marking the same region twice is idempotent', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
expect(res.status).toBe(200);
});
it('ATLAS-010 — GET /regions returns marked regions grouped by country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.post('/api/addons/atlas/region/DE-BY/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Bayern', country_code: 'DE' });
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('regions');
const deRegions = res.body.regions['DE'] as any[];
expect(deRegions).toBeDefined();
const codes = deRegions.map((r: any) => r.code);
expect(codes).toContain('DE-NW');
expect(codes).toContain('DE-BY');
});
it('ATLAS-011 — DELETE /region/:code/mark unmarks a region', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const del = await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user.id));
const deRegions = res.body.regions['DE'] as any[] | undefined;
const codes = (deRegions || []).map((r: any) => r.code);
expect(codes).not.toContain('DE-NW');
});
it('ATLAS-011 — unmark last region in country also unmarks the parent country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).not.toContain('DE');
});
it('ATLAS-011 — unmark one region keeps country when another region remains', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.post('/api/addons/atlas/region/DE-BY/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Bayern', country_code: 'DE' });
await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).toContain('DE');
});
it('ATLAS-011 — regions are isolated between users', async () => {
const { user: user1 } = createUser(testDb);
const { user: user2 } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user1.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user2.id));
expect(res.status).toBe(200);
const deRegions = res.body.regions['DE'] as any[] | undefined;
expect(deRegions).toBeUndefined();
});
});
+1
View File
@@ -58,4 +58,5 @@
<!-- Other --> <!-- Other -->
<Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config> <Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config> <Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config>
<Config Name="MCP_MAX_SESSION_PER_USER" Target="MCP_MAX_SESSION_PER_USER" Default="5" Mode="" Description="Max concurrent MCP sessions per user." Type="Variable" Display="advanced" Required="false" Mask="false">5</Config>
</Container> </Container>