Compare commits

...

27 Commits

Author SHA1 Message Date
github-actions[bot] 504195a324 chore: bump version to 2.9.11 [skip ci] 2026-04-07 11:18:45 +00:00
jubnl 47b880221d fix(oidc): resolve login/logout loop in OIDC-only mode
Three distinct bugs caused infinite OIDC redirect loops:

1. After logout, navigating to /login with no signal to suppress the
   auto-redirect caused the login page to immediately re-trigger the
   OIDC flow. Fixed by passing `{ state: { noRedirect: true } }` via
   React Router's navigation state (not URL params, which were fragile
   due to async cleanup timing) from all logout call sites.

2. On the OIDC callback page (/login?oidc_code=...), App.tsx's
   mount-level loadUser() fired concurrently with the LoginPage's
   exchange fetch. The App-level call had no cookie yet and got a 401,
   which (if it resolved after the successful exchange loadUser()) would
   overwrite isAuthenticated back to false. Fixed by skipping loadUser()
   in App.tsx when the initial path is /login.

3. React 18 StrictMode double-invokes useEffect. The first run called
   window.history.replaceState to clean the oidc_code from the URL
   before starting the async exchange, so the second run saw no
   oidc_code and fell through to the getAppConfig auto-redirect, firing
   window.location.href = '/api/auth/oidc/login' before the exchange
   could complete. Fixed by adding a useRef guard to prevent
   double-execution and moving replaceState into the fetch callbacks so
   the URL is only cleaned after the exchange resolves.

Also adds login.oidcLoggedOut translation key in all 14 languages to
show "You have been logged out" instead of the generic OIDC-only
message when landing on /login after an intentional logout.

Closes #491
2026-04-07 13:18:24 +02:00
github-actions[bot] a6ea73eab6 chore: bump version to 2.9.10 [skip ci] 2026-04-06 10:57:06 +00:00
Maurice 4ba6005ca3 fix(dayplan): resolve duplicate reservation display, date off-by-one, and missing day_id on edit
- Exclude place-assigned reservations from timeline to prevent duplicate display
- Use selected day's date instead of today when entering time without date
- Pass day_id when updating reservations, not only when creating
2026-04-06 12:56:54 +02:00
github-actions[bot] 09ab829b17 chore: bump version to 2.9.9 [skip ci] 2026-04-06 09:32:20 +00:00
Maurice 66a057a070 fix(bookings): resolve date handling and file auth bugs
- Clear reservation_time fields when switching booking type to hotel (#459)
- Parse date-only reservation_end_time correctly on edit (#455)
- Show end date on booking cards for date-only values (#455)
- Add auth token to file download links in bookings (#454)
- Account for timezone offsets in flight time validation (#456)
2026-04-06 11:32:06 +02:00
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
44 changed files with 447 additions and 92 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_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_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -303,6 +304,7 @@ trek.yourdomain.com {
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `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
+2
View File
@@ -53,6 +53,8 @@ env:
# Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "2.8.4",
"version": "2.9.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "2.8.4",
"version": "2.9.11",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.8.4",
"version": "2.9.11",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -82,7 +82,7 @@ export default function App() {
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/')) {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) {
loadUser()
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
+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),
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),
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 = {
+1 -1
View File
@@ -53,7 +53,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
const handleLogout = () => {
logout()
navigate('/login')
navigate('/login', { state: { noRedirect: true } })
}
const toggleDarkMode = () => {
@@ -956,6 +956,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setLightboxUserId(photo.user_id)
setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
setLightboxInfoLoading(true)
apiClient.get(buildProviderAssetUrl(photo, 'info'))
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
}
const exifContent = lightboxInfo ? (
@@ -248,8 +248,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const getTransportForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
return reservations.filter(r => {
if (!r.reservation_time || r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r)
@@ -341,14 +343,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
initTransportPositions(dayId)
}
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order
// Places keep their order_index ordering — only transports are inserted based on time.
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
const baseItems = [
...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 })),
].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 => ({
type: 'transport' as const,
data: r,
@@ -360,19 +361,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
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]
for (let ti = 0; ti < timedTransports.length; ti++) {
const timed = timedTransports[ti]
const minutes = timed.minutes
// Use persisted position if available
if (timed.data.day_plan_position != null) {
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data })
// Use per-day position if explicitly set by user reorder
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
if (perDayPos != null) {
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
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
for (const item of result) {
if (item.type === 'place') {
@@ -500,10 +502,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (transportUpdates.length) {
for (const tu of transportUpdates) {
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)
await reservationsApi.updatePositions(tripId, transportUpdates)
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
}
if (prevAssignmentIds.length) {
const capturedDayId = dayId
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import apiClient from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
@@ -9,6 +10,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { getAuthUrl } from '../../api/authUrl'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [
@@ -71,6 +73,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const { t, locale } = useTranslation()
const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
@@ -111,6 +114,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (rawEnd.includes('T')) {
endDate = rawEnd.split('T')[0]
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
} else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) {
endDate = rawEnd
endTime = ''
}
setForm({
title: reservation.title || '',
@@ -139,7 +145,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_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
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 {
setForm({
@@ -164,6 +170,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const startDate = form.reservation_time.split('T')[0]
const startTime = form.reservation_time.split('T')[1] || '00:00'
const endTime = form.reservation_end_time || '00:00'
// For flights, compare in UTC using timezone offsets
if (form.type === 'flight') {
const parseOffset = (tz: string): number | null => {
if (!tz) return null
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
if (!m) return null
const sign = m[1] === '+' ? 1 : -1
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
}
const depOffset = parseOffset(form.meta_departure_timezone)
const arrOffset = parseOffset(form.meta_arrival_timezone)
if (depOffset === null || arrOffset === null) return false
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
return arrMinutes <= depMinutes
}
const startFull = `${startDate}T${startTime}`
const endFull = `${form.end_date}T${endTime}`
return endFull <= startFull
@@ -196,11 +218,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (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 (form.budget_category) metadata.budget_category = form.budget_category
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
assignment_id: form.assignment_id || null,
@@ -208,9 +233,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}
// Auto-create/update budget entry if price is set, or signal removal if cleared
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
if (isBudgetEnabled) {
saveData.create_budget_entry = form.price && parseFloat(form.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 (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
@@ -358,7 +385,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
onChange={t => {
const [d] = (form.reservation_time || '').split('T')
const date = d || new Date().toISOString().split('T')[0]
const selectedDay = days.find(dy => dy.id === selectedDayId)
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
set('reservation_time', t ? `${date}T${t}` : date)
}}
/>
@@ -559,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
<a href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
// Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation
@@ -643,33 +671,37 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Price + Budget Category */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
{/* Price + Budget Category — only shown when budget addon is enabled */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
{/* Actions */}
@@ -10,6 +10,7 @@ import {
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react'
import { getAuthUrl } from '../../api/authUrl'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry {
@@ -138,7 +139,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
{fmtDate(r.reservation_time)}
{r.reservation_end_time?.includes('T') && r.reservation_end_time.split('T')[0] !== r.reservation_time.split('T')[0] && (
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</div>
@@ -252,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
{attachedFiles.map(f => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<a key={f.id} href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</a>
@@ -575,7 +575,7 @@ export default function AccountTab(): React.ReactElement {
try {
await authApi.deleteOwnAccount()
logout()
navigate('/login')
navigate('/login', { state: { noRedirect: true } })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
setShowDeleteConfirm(false)
+1
View File
@@ -367,6 +367,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.',
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
'login.mfaTitle': 'المصادقة الثنائية',
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
+1
View File
@@ -362,6 +362,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Falha no login de demonstração',
'login.oidcSignIn': 'Entrar com {name}',
'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.',
'login.oidcLoggedOut': 'Você foi desconectado. Entre novamente usando o provedor SSO.',
'login.demoHint': 'Experimente a demonstração — sem cadastro',
'login.mfaTitle': 'Autenticação em duas etapas',
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
+1
View File
@@ -362,6 +362,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Přihlášení do dema se nezdařilo',
'login.oidcSignIn': 'Přihlásit se přes {name}',
'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.',
'login.oidcLoggedOut': 'Byl jste odhlášen. Přihlaste se znovu přes SSO poskytovatele.',
'login.demoHint': 'Vyzkoušejte demo registrace není nutná',
'login.mfaTitle': 'Dvoufaktorové ověření',
'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.',
+1
View File
@@ -362,6 +362,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Demo-Login fehlgeschlagen',
'login.oidcSignIn': 'Anmelden mit {name}',
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
'login.oidcLoggedOut': 'Du wurdest abgemeldet. Melde dich erneut über deinen SSO-Anbieter an.',
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
+1
View File
@@ -383,6 +383,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Demo login failed',
'login.oidcSignIn': 'Sign in with {name}',
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
'login.oidcLoggedOut': 'You have been logged out. Sign in again using your SSO provider.',
'login.demoHint': 'Try the demo — no registration needed',
'login.mfaTitle': 'Two-factor authentication',
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
+1
View File
@@ -1490,6 +1490,7 @@ const es: Record<string, string> = {
'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña',
'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.',
'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.',
// Settings (2.6.2)
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
+1
View File
@@ -369,6 +369,7 @@ const fr: Record<string, string> = {
'login.demoFailed': 'Échec de la connexion démo',
'login.oidcSignIn': 'Se connecter avec {name}',
'login.oidcOnly': 'L\'authentification par mot de passe est désactivée. Veuillez vous connecter via votre fournisseur SSO.',
'login.oidcLoggedOut': 'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.',
'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
// Register
+1
View File
@@ -362,6 +362,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Demo bejelentkezés sikertelen',
'login.oidcSignIn': 'Bejelentkezés ezzel: {name}',
'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.',
'login.oidcLoggedOut': 'Kijelentkeztél. Jelentkezz be újra az SSO szolgáltatódon keresztül.',
'login.demoHint': 'Próbáld ki a demót — regisztráció nélkül',
'login.mfaTitle': 'Kétfaktoros hitelesítés',
'login.mfaSubtitle': 'Add meg a 6 jegyű kódot a hitelesítő alkalmazásból.',
+1
View File
@@ -362,6 +362,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Accesso demo fallito',
'login.oidcSignIn': 'Accedi con {name}',
'login.oidcOnly': 'L\'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.',
'login.oidcLoggedOut': 'Sei stato disconnesso. Accedi nuovamente tramite il tuo provider SSO.',
'login.demoHint': 'Prova la demo — nessuna registrazione necessaria',
'login.mfaTitle': 'Autenticazione a due fattori',
'login.mfaSubtitle': 'Inserisci il codice a 6 cifre dalla tua app authenticator.',
+1
View File
@@ -369,6 +369,7 @@ const nl: Record<string, string> = {
'login.demoFailed': 'Demo-login mislukt',
'login.oidcSignIn': 'Inloggen met {name}',
'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.',
'login.oidcLoggedOut': 'Je bent uitgelogd. Log opnieuw in via je SSO-provider.',
'login.demoHint': 'Probeer de demo — geen registratie nodig',
// Register
+1
View File
@@ -329,6 +329,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Nie udało się zalogować do wersji demonstracyjnej',
'login.oidcSignIn': 'Zaloguj się z {name}',
'login.oidcOnly': 'Uwierzytelnianie hasłem jest wyłączone. Zaloguj się za pomocą swojego dostawcy SSO.',
'login.oidcLoggedOut': 'Zostałeś wylogowany. Zaloguj się ponownie za pomocą swojego dostawcy SSO.',
'login.demoHint': 'Wypróbuj demo — nie wymaga rejestracji',
'login.mfaTitle': 'Uwierzytelnianie dwuskładnikowe',
'login.mfaSubtitle': 'Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.',
+1
View File
@@ -369,6 +369,7 @@ const ru: Record<string, string> = {
'login.demoFailed': 'Ошибка демо-входа',
'login.oidcSignIn': 'Войти через {name}',
'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.',
'login.oidcLoggedOut': 'Вы вышли из системы. Войдите снова через вашего провайдера SSO.',
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
// Register
+1
View File
@@ -369,6 +369,7 @@ const zh: Record<string, string> = {
'login.demoFailed': '演示登录失败',
'login.oidcSignIn': '通过 {name} 登录',
'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。',
'login.demoHint': '试用演示——无需注册',
// Register
+1
View File
@@ -353,6 +353,7 @@ const zhTw: Record<string, string> = {
'login.demoFailed': '演示登入失敗',
'login.oidcSignIn': '透過 {name} 登入',
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。',
'login.demoHint': '試用演示——無需註冊',
// Register
+1 -1
View File
@@ -1551,7 +1551,7 @@ docker run -d --name trek \\
await adminApi.rotateJwtSecret()
setShowRotateJwtModal(false)
logout()
navigate('/login')
navigate('/login', { state: { noRedirect: true } })
} catch {
toast.error(t('common.error'))
setRotatingJwt(false)
+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) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const name = (f.properties?.name || '').toLowerCase()
if (visitedRegionNames.has(name)) return true
// Fuzzy: check if any visited name is contained in feature name or vice versa
for (const vn of visitedRegionNames) {
if (name.includes(vn) || vn.includes(name)) return true
}
const nameEn = (f.properties?.name_en || '').toLowerCase()
if (nameEn && visitedRegionNames.has(nameEn)) return true
return false
}
@@ -535,15 +533,16 @@ export default function AtlasPage(): React.ReactElement {
},
onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || ''
const regionNameEn = feature?.properties?.name_en || ''
const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || ''
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
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', () => {
if (!countryA2) return
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) {
setConfirmActionRef.current({
type: 'unmark-region',
+15 -7
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
@@ -29,10 +29,13 @@ export default function LoginPage(): React.ReactElement {
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false)
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
const location = useLocation()
const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect
const redirectTarget = useMemo(() => {
const params = new URLSearchParams(window.location.search)
@@ -63,11 +66,13 @@ export default function LoginPage(): React.ReactElement {
}
if (oidcCode) {
if (exchangeInitiated.current) return
exchangeInitiated.current = true
setIsLoading(true)
window.history.replaceState({}, '', '/login')
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
.then(r => r.json())
.then(async data => {
window.history.replaceState({}, '', '/login')
if (data.token) {
await loadUser()
navigate('/dashboard', { replace: true })
@@ -75,7 +80,10 @@ export default function LoginPage(): React.ReactElement {
setError(data.error || 'OIDC login failed')
}
})
.catch(() => setError('OIDC login failed'))
.catch(() => {
window.history.replaceState({}, '', '/login')
setError('OIDC login failed')
})
.finally(() => setIsLoading(false))
return
}
@@ -96,12 +104,12 @@ export default function LoginPage(): React.ReactElement {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) {
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login'
}
}
})
}, [navigate, t])
}, [navigate, t, noRedirect])
const handleDemoLogin = async (): Promise<void> => {
setError('')
@@ -527,7 +535,7 @@ export default function LoginPage(): React.ReactElement {
{oidcOnly ? (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{t('login.oidcOnly')}</p>
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{noRedirect ? t('login.oidcLoggedOut') : t('login.oidcOnly')}</p>
{error && (
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
{error}
+9 -1
View File
@@ -137,6 +137,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
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 => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
@@ -423,7 +431,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
if (data.type === 'hotel') {
+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_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_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes:
- ./data:/app/data
- ./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
# 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.
# If both are set the admin account is created with these credentials.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "2.8.4",
"version": "2.9.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "2.8.4",
"version": "2.9.11",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "2.8.4",
"version": "2.9.11",
"main": "src/index.ts",
"scripts": {
"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://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://router.project-osrm.org/route/v1"
"https://router.project-osrm.org/route/v1/"
],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
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 (?, ?)');
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) {
+2 -1
View File
@@ -18,7 +18,8 @@ interface McpSession {
const sessions = new Map<string, McpSession>();
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 parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
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' });
updatePositions(tripId, positions);
const { day_id } = req.body;
updatePositions(tripId, positions, day_id);
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) => {
+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) {
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE 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));
}
+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)) {
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; }
const info: RegionInfo = {
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);
}
} catch (error) {
if (response.headersSent) return;
if (response.headersSent) {
response.end();
return;
}
if (error instanceof SsrfBlockedError) {
response.status(400).json({ error: error.message });
} else {
+53 -12
View File
@@ -6,7 +6,7 @@ export function verifyTripAccess(tripId: string | number, userId: 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,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
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
WHERE r.trip_id = ?
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) {
@@ -117,14 +137,35 @@ export function createReservation(tripId: string | number, data: CreateReservati
return { reservation, accommodationCreated };
}
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[]) {
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 updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[], dayId?: number | string) {
if (dayId) {
// Per-day positions for multi-day reservations
const stmt = db.prepare('INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
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);
} 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) {
@@ -193,8 +234,8 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ?
`).run(
title || null,
reservation_time !== undefined ? (reservation_time || null) : current.reservation_time,
reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time,
(type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time),
(type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time),
location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes,
+181
View File
@@ -202,3 +202,184 @@ describe('Bucket list', () => {
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 -->
<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_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>