Compare commits

..

23 Commits

Author SHA1 Message Date
jubnl efa3829d51 test(pdf): add missing day_id to transport reservation fixture 2026-04-23 10:46:24 +02:00
jubnl 1609996fc7 fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847)
Reservations were matched to days by pickup date only, so the end-day
card (e.g. car Return, flight Arrival) was silently dropped from the PDF.
Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id
span, show reservation_end_time on end days, prefix title with phase label
(Return/Arrival/etc.), and use per-day position for sort order.
2026-04-23 10:38:20 +02:00
jubnl 70459dc085 fix(journey): make sort_order authoritative for within-day entry ordering
Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.

- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019

Closes #846
2026-04-23 10:28:21 +02:00
github-actions[bot] 311647fd46 chore: bump version to 3.0.5 [skip ci] 2026-04-23 08:07:13 +00:00
Xre0uS 28dbd86d03 fix(files): open attachments only in new tab (#840)
window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead.
2026-04-23 10:06:56 +02:00
github-actions[bot] 842d9760df chore: bump version to 3.0.4 [skip ci] 2026-04-23 07:13:48 +00:00
Julien G. 58218ff5f6 fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845)
OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery
doc's issuer for id_token comparison instead of rejecting a path
mismatch as an error. Authentik (and similar realm-path providers)
return a canonical issuer like /application/o/<slug>/ that differs
from the operator's base OIDC_ISSUER. Strict equality blocked login
in 3.x despite working in v2. Default discovery (no custom URL) keeps
the strict check. Adds OIDC-SVC-037/038/039.

UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h
paddingBottom offset that other overlays already use. On mobile portrait
the action buttons were hidden behind the sticky bottom nav bar.

Closes #843
Closes #844
2026-04-23 09:13:35 +02:00
github-actions[bot] 83be5fc92a chore: bump version to 3.0.3 [skip ci] 2026-04-22 20:16:47 +00:00
Julien G. 7798d2a3fd fix(oidc): normalize id_token iss claim before issuer comparison (#837)
jwt.verify does an exact string match on the issuer. Providers like
Authentik include a trailing slash in the id_token iss claim while the
configured issuer is already normalized (no trailing slash), causing
every login attempt to fail with jwt issuer invalid.

Move the issuer check out of jwt.verify options and apply the same
trailing-slash normalization used in the discovery doc validation.
Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing
slash, wrong issuer, and wrong audience cases.

Closes #834
2026-04-22 22:16:33 +02:00
github-actions[bot] ec1ed60117 chore: bump version to 3.0.2 [skip ci] 2026-04-22 19:25:28 +00:00
Julien G. ed4c21eade Merge pull request #835 from mauriceboe/fix/oidc-issuer-trailing-slash
fix(oidc): normalize discovery doc issuer before trailing slash comparison
2026-04-22 21:25:15 +02:00
jubnl 9093948ff6 test(systemNotices): exclude v3 upgrade notices from login_count-only tests
Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.
2026-04-22 21:19:04 +02:00
jubnl 2cea4d73aa fix(oidc): normalize discovery doc issuer before comparison
Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.

Closes #834
2026-04-22 21:14:29 +02:00
github-actions[bot] a2a6f52e6e chore: bump version to 3.0.1 [skip ci] 2026-04-22 17:58:18 +00:00
Maurice 0978b40b6d Merge pull request #832 from mauriceboe/fix/reservations-day-id-mismatch
fix(reservations): restore correct day assignment for non-transport bookings
2026-04-22 19:58:03 +02:00
Maurice 6155b6dc86 fix(reservations): restore correct day assignment for non-transport bookings
v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.

- Migration 122: for every non-hotel reservation, null out any
  day_id / end_day_id that does not match the reservation's time,
  then backfill it from reservation_time / reservation_end_time.
  Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
  derive day_id / end_day_id from reservation_time /
  reservation_end_time when the client didn't send one explicitly,
  so the mismatch cannot reappear on new or edited bookings.
  Hotels are skipped because they store their date range on the
  linked day_accommodation.
2026-04-22 19:47:22 +02:00
jubnl 314486325e fix: resolve dead wiki links across install and config pages 2026-04-22 19:21:53 +02:00
github-actions[bot] 523bca3a20 chore: bump version to 3.0.0 [skip ci] 2026-04-22 16:59:12 +00:00
Maurice d5be528d4b Merge pull request #758 from mauriceboe/dev
V3.0.0
2026-04-22 18:58:23 +02:00
Julien G. 3ada075b1a Merge pull request #831 from mauriceboe/fix/transport-modal-price-budget-fields
fix: restore Price and Budget Category fields in Edit Transport dialog
2026-04-22 18:55:53 +02:00
jubnl afce302b59 fix: restore price and budget category fields in TransportModal 2026-04-22 18:50:42 +02:00
Maurice 8e8433fa9d docs: align Home.md + README addon list + Tags/Photo-Providers wording with dev state
- Home.md: addon list (9 real addons), MCP numbers (150+ tools, 30 resources, 27 scopes), admin-seeding text
- README.md: expand addon list from 5 to 9 (Lists/Budget/Documents/Naver/MCP in, Dashboard widgets out)
- Photo-Providers.md: 'Memories addon' -> photo provider toggles under Journey
- Admin-Addons.md: Journey works without photo providers; they are optional sub-toggles
- Tags-and-Categories.md: add Personal Tags section (user-scoped, MCP-only for now)
2026-04-22 18:22:22 +02:00
Maurice ff42fa0b8c docs: sync README with current dev state
- MCP: 80+ tools/27 resources -> 150+ tools/30 resources
- MCP: 24 -> 27 OAuth scopes
- i18n: 14 -> 15 languages
- admin seeding on first boot (not first-to-register)
- nginx: client_max_body_size 50m -> 500m, add proxy_read_timeout 86400 on /ws
2026-04-22 18:10:27 +02:00
36 changed files with 737 additions and 121 deletions
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: init
strategy: clone
+14 -8
View File
@@ -127,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Collab** — chat, notes, polls, day-by-day attendance
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
- **Dashboard widgets** — currency converter and timezone clocks
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
@@ -152,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
@@ -172,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. The first user to register becomes admin.
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
<div align="center">
@@ -338,7 +342,8 @@ server {
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
client_max_body_size 50m;
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
location / {
proxy_pass http://localhost:3000;
@@ -355,6 +360,7 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 2.9.14
version: 3.0.5
description: Minimal Helm chart for TREK app
appVersion: "2.9.14"
appVersion: "3.0.5"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "2.9.14",
"version": "3.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "2.9.14",
"version": "3.0.5",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.9.14",
"version": "3.0.5",
"private": true,
"type": "module",
"scripts": {
@@ -78,6 +78,7 @@ const transportReservation = {
id: 400,
title: 'Flight to Rome',
type: 'flight',
day_id: 10,
reservation_time: '2025-06-01T14:30:00',
confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
+47 -8
View File
@@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
const pdfGetDayOrder = (d: Day) => d.day_number
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (!startId || startId === endId) return 'single'
if (dayId === startId) return 'start'
if (dayId === endId) return 'end'
return 'middle'
}
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
const phase = pdfGetSpanPhase(r, dayId)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
}
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
if (phase === 'single') return null
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
if (r.type === 'hotel') return false
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (startId == null) return false
if (endId !== startId) {
const startDay = sorted.find(d => d.id === startId)
const endDay = sorted.find(d => d.id === endId)
const thisDay = sorted.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
}
return startId === dayId
})
// Build day HTML
const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
// Reservations for this day (hotel rendered via accommodations block)
const dayReservations = (reservations || []).filter(r => {
if (!r.reservation_time || r.type === 'hotel') return false
return day.date && r.reservation_time.split('T')[0] === day.date
})
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
const dayReservations = pdfGetTransportForDay(day.id)
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayReservations.forEach(r => {
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'reservation', k: pos, data: r })
})
merged.sort((a, b) => a.k - b.k)
@@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
const locationLine = r.location || meta.location || ''
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
const phase = pdfGetSpanPhase(r, day.id)
const spanLabel = pdfGetSpanLabel(r, phase)
const displayTime = pdfGetDisplayTime(r, day.id)
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
return `
<div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span>
<div class="note-body">
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
@@ -7,6 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types'
@@ -75,6 +77,8 @@ const defaultForm = {
arrival_time: '',
confirmation_number: '',
notes: '',
price: '',
budget_category: '',
meta_airline: '',
meta_flight_number: '',
meta_train_number: '',
@@ -94,6 +98,13 @@ interface TransportModalProps {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -126,6 +137,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
@@ -139,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({})
setToPick({})
}
}, [isOpen, reservation, selectedDayId])
}, [isOpen, reservation, selectedDayId, budgetItems])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -173,6 +186,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
@@ -200,6 +217,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints,
needs_review: false,
}
if (isBudgetEnabled) {
(payload as any).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 }
}
await onSave(payload)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
@@ -422,6 +444,40 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Price + Budget Category */}
{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.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
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>
)}
</>
)}
</form>
</Modal>
)
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
<div
@@ -42,7 +42,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
<div
+31
View File
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
expect(useJourneyStore.getState().loading).toBe(false);
});
// ── reorderEntries ───────────────────────────────────────────────────────
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
useJourneyStore.setState({ current: detail });
server.use(
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
);
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
expect(ids).toEqual([202, 201, 203]);
});
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
useJourneyStore.setState({ current: detail });
server.use(
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
);
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
expect(ids).toEqual([211, 212]);
});
// ── clear ────────────────────────────────────────────────────────────────
it('FE-STORE-JOURNEY-015: clear resets state', () => {
+2 -4
View File
@@ -223,10 +223,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
)
entries.sort((a, b) => {
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
const atime = a.entry_time || ''
const btime = b.entry_time || ''
if (atime !== btime) return atime.localeCompare(btime)
return (a.sort_order || 0) - (b.sort_order || 0)
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
return a.id - b.id
})
return { current: { ...s.current, entries } }
})
+27 -6
View File
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
}
// navigator.standalone is true only on iOS when running as an
// add-to-home-screen PWA. In that context, target="_blank" hands off to
// Safari, which cannot access blob URLs sandboxed to the WebView.
function isIosStandalone(): boolean {
return (navigator as any).standalone === true
}
/**
* Fetches a protected file using cookie auth (credentials: include) and
* triggers a browser download. Works inside PWA standalone mode because the
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
* (including text/html and image/svg+xml which can execute script) are forced
* to download so that an uploaded file cannot run code in the TREK origin.
*
* Falls back to a download trigger if the popup is blocked.
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
* than window.open(). window.open() called with the "noreferrer"/"noopener"
* window feature returns null per spec, which previously made the popup-block
* fallback trigger a download in the *current* tab on top of the new-tab open
* i.e. the file opened twice. The anchor approach avoids that ambiguity:
* the new tab is opened by the browser's normal link-handling path, and no
* spurious in-page download is triggered.
*/
export async function openFile(url: string, filename?: string): Promise<void> {
assertRelativeUrl(url)
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
return
}
const win = window.open(blobUrl, '_blank', 'noreferrer')
if (win) {
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
} else {
// Popup blocked — fall back to download
// iOS PWA: target="_blank" would open Safari, which can't access the blob
if (isIosStandalone()) {
triggerAnchorDownload(blobUrl, filename)
return
}
const a = document.createElement('a')
a.href = blobUrl
a.target = '_blank'
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
// Keep the blob URL alive long enough for the new tab to load it, then
// clean up the DOM node and revoke the URL.
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
}
+53 -16
View File
@@ -74,32 +74,42 @@ describe('downloadFile', () => {
})
describe('openFile', () => {
it('fetches with credentials:include and opens blob URL in new tab', async () => {
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
vi.stubGlobal('fetch', makeFetchMock(200))
const mockWin = { closed: false }
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/doc.pdf')
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
expect(URL.createObjectURL).toHaveBeenCalled()
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
// Must NOT call window.open — that path returns null when noreferrer is
// set, which previously caused the file to also open in the current tab.
expect(openSpy).not.toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalledTimes(1)
// The anchor used to open the new tab must be target=_blank, must NOT
// carry a `download` attribute (otherwise it would download in-page
// instead of opening), and must use rel=noopener noreferrer.
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
expect(anchor.target).toBe('_blank')
expect(anchor.rel).toBe('noopener noreferrer')
expect(anchor.hasAttribute('download')).toBe(false)
// Revoke happens after 30s timeout
vi.runAllTimers()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
it('falls back to anchor download when popup is blocked', async () => {
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
vi.stubGlobal('fetch', makeFetchMock(200))
vi.spyOn(window, 'open').mockReturnValue(null)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/doc.pdf')
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
expect(clickSpy).toHaveBeenCalled()
vi.runAllTimers()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
// Exactly ONE anchor click — opening the new tab. No fallback download.
expect(clickSpy).toHaveBeenCalledTimes(1)
})
it('throws on 401 response', async () => {
@@ -108,28 +118,55 @@ describe('openFile', () => {
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/malicious.html')
await openFile('/uploads/files/malicious.html', 'malicious.html')
// Must NOT open inline — download anchor clicked instead
expect(openSpy).not.toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalledTimes(1)
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
expect(anchor.download).toBe('malicious.html')
})
it('forces download for SVG MIME type', async () => {
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
vi.spyOn(window, 'open').mockReturnValue({} as Window)
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/malicious.svg')
expect(window.open).not.toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalled()
expect(openSpy).not.toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalledTimes(1)
})
it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
vi.stubGlobal('fetch', makeFetchMock(200))
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
// Simulate iOS PWA (Add-to-Home-Screen) context
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
try {
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
// Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank),
// because target="_blank" in iOS PWA would hand off to Safari which cannot
// read the in-WebView blob URL.
expect(clickSpy).toHaveBeenCalledTimes(1)
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
expect(anchor.target).toBe('')
expect(anchor.download).toBe('doc.pdf')
} finally {
// Clean up the non-standard iOS-only property we forced above.
delete (navigator as any).standalone
}
})
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "2.9.14",
"version": "3.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "2.9.14",
"version": "3.0.5",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "2.9.14",
"version": "3.0.5",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
+87
View File
@@ -2043,6 +2043,93 @@ function runMigrations(db: Database.Database): void {
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)');
},
// Migration 122: Correct stale day_id / end_day_id on non-transport
// reservations. Migration 110 only backfilled transport types; tours,
// restaurants, events and "other" bookings kept a stale day_id from
// older code paths that often defaulted to the first day of the trip.
// Starting with v3.0.0 the planner renders reservations by day_id
// instead of reservation_time, so those stale rows show up on the
// wrong day. This migration nulls out day_id / end_day_id values that
// don't match the reservation's time and then backfills them from
// reservation_time / reservation_end_time.
() => {
db.exec(`
UPDATE reservations
SET day_id = NULL
WHERE reservation_time IS NOT NULL
AND day_id IS NOT NULL
AND type != 'hotel'
AND NOT EXISTS (
SELECT 1 FROM days d
WHERE d.id = reservations.day_id
AND d.date = substr(reservations.reservation_time, 1, 10)
)
`);
db.exec(`
UPDATE reservations
SET end_day_id = NULL
WHERE reservation_end_time IS NOT NULL
AND end_day_id IS NOT NULL
AND type != 'hotel'
AND NOT EXISTS (
SELECT 1 FROM days d
WHERE d.id = reservations.end_day_id
AND d.date = substr(reservations.reservation_end_time, 1, 10)
)
`);
db.exec(`
UPDATE reservations
SET day_id = (
SELECT d.id FROM days d
WHERE d.trip_id = reservations.trip_id
AND d.date = substr(reservations.reservation_time, 1, 10)
LIMIT 1
)
WHERE type != 'hotel'
AND reservation_time IS NOT NULL
AND day_id IS NULL
`);
db.exec(`
UPDATE reservations
SET end_day_id = (
SELECT d.id FROM days d
WHERE d.trip_id = reservations.trip_id
AND d.date = substr(reservations.reservation_end_time, 1, 10)
LIMIT 1
)
WHERE type != 'hotel'
AND reservation_end_time IS NOT NULL
AND end_day_id IS NULL
AND substr(reservations.reservation_end_time, 1, 10)
!= substr(reservations.reservation_time, 1, 10)
`);
},
// #846: make sort_order authoritative within a day. Previous ORDER BY put
// entry_time before sort_order, silently ignoring reorder clicks when two
// same-date entries had different times. Backfill renumbers using the old
// effective key (entry_time ASC, id ASC) so existing journeys retain their
// current visual order.
() => {
db.exec(`
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY journey_id, entry_date
ORDER BY entry_time ASC, id ASC
) - 1 AS rn
FROM journey_entries
)
UPDATE journey_entries
SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id)
`);
db.exec(
'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' +
'ON journey_entries(journey_id, entry_date, sort_order)'
);
},
];
if (currentVersion < migrations.length) {
+1 -1
View File
@@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => {
tokenData.id_token,
doc,
config.clientId,
config.issuer,
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
);
if (idVerify.ok !== true) {
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
+18 -5
View File
@@ -120,7 +120,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
if (!journey) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
@@ -306,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
).all(journeyId, tripId) as { source_place_id: number }[];
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
// Track next sort_order per date so synced skeletons get unique, sequential positions.
const dateMaxOrder = new Map<string, number>();
const maxRows = db.prepare(
'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date'
).all(journeyId) as { entry_date: string; m: number }[];
for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
for (const place of places) {
if (existingPlaceIds.has(place.id)) continue;
existingPlaceIds.add(place.id);
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
dateMaxOrder.set(entryDate, nextOrder);
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
@@ -320,7 +329,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
journeyId, tripId, place.id, authorId,
place.name, entryDate, entryTime,
place.address || place.name, place.lat || null, place.lng || null,
place.day_number || 0, now, now
nextOrder, now, now
);
}
}
@@ -367,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
const entryDate = place.day_date;
const maxOrder = db.prepare(
'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
).get(link.journey_id, entryDate) as { m: number | null };
const nextOrder = (maxOrder?.m ?? -1) + 1;
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
link.journey_id, tripId, placeId, journey.user_id,
place.name, entryDate, place.assignment_time || place.place_time || null,
place.address || place.name, place.lat || null, place.lng || null,
now, now
nextOrder, now, now
);
}
}
@@ -451,7 +464,7 @@ export function listEntries(journeyId: number, userId: number) {
if (!canAccessJourney(journeyId, userId)) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
+22 -6
View File
@@ -140,11 +140,21 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
const doc = (await res.json()) as OidcDiscoveryDoc;
// Validate that the discovery doc's issuer matches the operator-configured
// one. A MITM or compromised doc could otherwise supply a crafted issuer
// that passes jwt.verify() because we used doc.issuer as the expected value.
if (doc.issuer && doc.issuer !== issuer) {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
// Validate that the discovery doc's issuer matches the operator-configured one.
// When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration
// and we reject. When the operator explicitly overrides the discovery URL (e.g.
// Authentik realm paths), the discovery doc's issuer is the canonical value —
// trust it and warn rather than blocking login.
const docIssuer = doc.issuer?.replace(/\/+$/, '') ?? '';
if (docIssuer && docIssuer !== issuer) {
if (discoveryUrl) {
console.warn(
`[OIDC] Discovery doc issuer "${doc.issuer}" differs from configured OIDC_ISSUER "${issuer}". ` +
`Using discovery doc issuer for id_token verification (custom OIDC_DISCOVERY_URL is set).`,
);
} else {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
}
}
doc._issuer = url;
discoveryCache = doc;
@@ -313,7 +323,6 @@ export async function verifyIdToken(
try {
const verified = jwt.verify(idToken, publicKey, {
algorithms: [alg as jwt.Algorithm],
issuer: expectedIssuer,
audience: clientId,
});
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
@@ -322,6 +331,13 @@ export async function verifyIdToken(
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
}
// Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik)
// include a trailing slash in the id_token iss claim.
const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : '';
if (tokenIssuer !== expectedIssuer) {
return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` };
}
return { ok: true, claims };
}
+67 -7
View File
@@ -43,6 +43,24 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] {
).all(reservationId) as ReservationEndpoint[];
}
// Resolve the day row whose date matches the date portion of an ISO-ish
// timestamp. Used to keep `day_id` / `end_day_id` in sync with
// `reservation_time` / `reservation_end_time` so non-transport bookings
// (tours, restaurants, events, ...) end up on the right day in the UI,
// which now filters by day_id instead of reservation_time.
function resolveDayIdFromTime(
tripId: string | number,
time: string | null | undefined,
): number | null {
if (!time) return null;
const datePart = time.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
const row = db
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
.get(tripId, datePart) as { id: number } | undefined;
return row?.id ?? null;
}
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
const insert = db.prepare(`
@@ -160,13 +178,26 @@ export function createReservation(tripId: string | number, data: CreateReservati
}
}
// Derive day_id / end_day_id from reservation_time when the client
// didn't explicitly set them (non-hotel bookings only — hotels store
// their date range on the linked day_accommodation).
const resolvedType = type || 'other';
let resolvedDayId: number | null = day_id ?? null;
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
resolvedDayId = resolveDayIdFromTime(tripId, reservation_time);
}
let resolvedEndDayId: number | null = end_day_id ?? null;
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time);
}
const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
day_id || null,
end_day_id ?? null,
resolvedDayId,
resolvedEndDayId,
place_id || null,
assignment_id || null,
title,
@@ -176,7 +207,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
confirmation_number || null,
notes || null,
status || 'pending',
type || 'other',
resolvedType,
resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0
@@ -290,6 +321,35 @@ export function updateReservation(id: string | number, tripId: string | number,
}
}
const resolvedType = (type ?? current.type) || 'other';
const nextReservationTime = resolvedType === 'hotel'
? null
: (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time);
const nextReservationEndTime = resolvedType === 'hotel'
? null
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
// day_id / end_day_id: honour an explicit value from the client,
// otherwise derive from the (possibly updated) reservation_time so the
// planner renders the booking on the correct day.
let nextDayId: number | null;
if (day_id !== undefined) {
nextDayId = day_id || null;
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
} else {
nextDayId = current.day_id ?? null;
}
let nextEndDayId: number | null;
if (end_day_id !== undefined) {
nextEndDayId = end_day_id ?? null;
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime);
} else {
nextEndDayId = (current as any).end_day_id ?? null;
}
db.prepare(`
UPDATE reservations SET
title = COALESCE(?, title),
@@ -310,13 +370,13 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ?
`).run(
title || null,
(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),
nextReservationTime,
nextReservationEndTime,
location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes,
day_id !== undefined ? (day_id || null) : current.day_id,
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
nextDayId,
nextEndDayId,
place_id !== undefined ? (place_id || null) : current.place_id,
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
status || null,
@@ -84,8 +84,9 @@ describe('GET /api/system-notices/active', () => {
it('returns empty array for non-first-login user with no applicable notices', async () => {
const { user } = createUser(testDb);
// login_count > 1 means firstLogin condition does not match for any notice
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
// login_count > 1 means firstLogin condition does not match for any notice;
// first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
@@ -122,7 +123,7 @@ describe('GET /api/system-notices/active', () => {
SYSTEM_NOTICES.push(TEST_NOTICE);
try {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const res = await request(app)
.get('/api/system-notices/active')
@@ -68,6 +68,7 @@ import {
removeContributor,
getSuggestions,
syncTripPlaces,
reorderEntries,
onPlaceCreated,
onPlaceUpdated,
onPlaceDeleted,
@@ -1465,3 +1466,108 @@ describe('addProviderPhoto — passphrase', () => {
expect(row?.passphrase).not.toBe('secret-pp');
});
});
// -- reorderEntries (#846) ----------------------------------------------------
function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } {
const now = Date.now();
const res = testDb.prepare(`
INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at)
VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?)
`).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now);
return { id: Number(res.lastInsertRowid) };
}
describe('reorderEntries', () => {
it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 });
const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 });
const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]);
expect(ok).toBe(true);
const entries = listEntries(journey.id, user.id)!;
const dayEntries = entries.filter(e => e.entry_date === '2026-08-01');
expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]);
});
it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => {
const { user } = createUser(testDb);
const j1 = createJourney(testDb, user.id);
const j2 = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' });
const ok = reorderEntries(j1.id, user.id, [entry.id]);
expect(ok).toBe(false);
});
it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 });
const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 });
const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 });
reorderEntries(journey.id, user.id, [day1b.id, day1a.id]);
const entries = listEntries(journey.id, user.id)!;
const day2Entry = entries.find(e => e.id === day2.id)!;
expect(day2Entry.sort_order).toBe(0);
});
});
describe('syncTripPlaces sort_order', () => {
it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, {
title: 'Order Trip',
start_date: '2026-09-01',
end_date: '2026-09-02',
});
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const p1 = createPlace(testDb, trip.id, { name: 'Place A' });
const p2 = createPlace(testDb, trip.id, { name: 'Place B' });
const p3 = createPlace(testDb, trip.id, { name: 'Place C' });
createDayAssignment(testDb, day.id, p1.id);
createDayAssignment(testDb, day.id, p2.id);
createDayAssignment(testDb, day.id, p3.id);
syncTripPlaces(journey.id, trip.id, user.id);
const rows = testDb.prepare(
'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC'
).all(journey.id) as { sort_order: number }[];
const orders = rows.map(r => r.sort_order);
expect(new Set(orders).size).toBe(orders.length);
expect(orders).toEqual([0, 1, 2]);
});
});
describe('onPlaceCreated sort_order', () => {
it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, {
title: 'Append Trip',
start_date: '2026-10-01',
end_date: '2026-10-02',
});
addTripToJourney(journey.id, trip.id, user.id);
const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string };
insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 });
const place = createPlace(testDb, trip.id, { name: 'Late Addition' });
createDayAssignment(testDb, day.id, place.id);
onPlaceCreated(trip.id, place.id);
const newEntry = testDb.prepare(
'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
).get(journey.id, place.id) as { sort_order: number } | undefined;
expect(newEntry).toBeDefined();
expect(newEntry!.sort_order).toBe(6);
});
});
@@ -4,6 +4,8 @@
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { generateKeyPairSync } from 'crypto';
import jwtLib from 'jsonwebtoken';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -50,6 +52,7 @@ import {
frontendUrl,
findOrCreateUser,
discover,
verifyIdToken,
} from '../../../src/services/oidcService';
const MOCK_CONFIG = {
@@ -216,6 +219,59 @@ describe('discover', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
});
it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => {
const doc = {
issuer: 'https://auth.example.com/application/o/myapp/',
authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/',
token_endpoint: 'https://auth.example.com/application/o/token/',
userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await discover(
'https://auth.example.com',
'https://auth.example.com/application/o/myapp/.well-known/openid-configuration',
);
expect(result.issuer).toBe(doc.issuer);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER'));
warnSpy.mockRestore();
});
it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => {
const doc = {
issuer: 'https://evil.example.com',
authorization_endpoint: 'https://unique-2.example.com/auth',
token_endpoint: 'https://unique-2.example.com/token',
userinfo_endpoint: 'https://unique-2.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
await expect(discover('https://unique-2.example.com')).rejects.toThrow(
'OIDC discovery issuer mismatch',
);
});
it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => {
const doc = {
issuer: 'https://auth.example.com/',
authorization_endpoint: 'https://auth.example.com/auth',
token_endpoint: 'https://auth.example.com/token',
userinfo_endpoint: 'https://auth.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await discover(
'https://auth.example.com',
'https://auth.example.com/.well-known/openid-configuration',
);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
@@ -460,3 +516,66 @@ describe('getUserInfo', () => {
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
});
});
// ── verifyIdToken ─────────────────────────────────────────────────────────────
describe('verifyIdToken', () => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
const ISSUER = 'https://auth.example.com/application/o/trek';
const CLIENT_ID = 'trek-client';
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
function mockJwks() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
}));
}
function makeToken(iss: string, overrides: object = {}) {
return jwtLib.sign(
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
privateKey,
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
);
}
const doc = { jwks_uri: JWKS_URI } as any;
afterEach(() => { vi.unstubAllGlobals(); });
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
mockJwks();
const token = makeToken(ISSUER);
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(true);
});
it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => {
mockJwks();
const token = makeToken(ISSUER + '/');
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(true);
});
it('OIDC-SVC-035: rejects token with wrong issuer', async () => {
mockJwks();
const token = makeToken('https://evil.example.com');
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
expect((result as any).error).toMatch('jwt issuer invalid');
});
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
mockJwks();
const token = makeToken(ISSUER, {});
const wrongAudToken = jwtLib.sign(
{ sub: 'user-sub', iss: ISSUER },
privateKey,
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
);
const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
});
});
+1 -1
View File
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
Some addons require credentials or environment variables before they are functional:
- **Journey**requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers).
- **Journey**works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers).
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
## Related pages
+7 -7
View File
@@ -48,7 +48,7 @@ Verified in `server/src/config.ts` (line 107):
## HTTPS / Proxy
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation.
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation.
| Variable | Description | Default |
|---|---|---|
@@ -62,7 +62,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
## OIDC / SSO
For setup instructions, see [OIDC-SSO].
For setup instructions, see [OIDC-SSO](OIDC-SSO).
| Variable | Description | Default |
|---|---|---|
@@ -110,7 +110,7 @@ Both variables must be set together. If either is omitted, the account is create
## MCP
For setup instructions, see [MCP-Overview].
For setup instructions, see [MCP-Overview](MCP-Overview).
| Variable | Description | Default |
|---|---|---|
@@ -129,7 +129,7 @@ For setup instructions, see [MCP-Overview].
## Related Pages
- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
- [OIDC-SSO] — complete OIDC configuration guide
- [MCP-Overview] — MCP server setup and rate limiting
- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data
- [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
- [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide
- [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting
- [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data
+13 -7
View File
@@ -30,17 +30,23 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- **Public Share Links** — share a read-only view of any trip
### Addons _(admin-toggleable)_
- **Lists** — packing lists and to-dos with templates, member assignments, optional bag tracking
- **Budget Planner** — expense tracker with category breakdown, splits, multi-currency
- **Documents** — file manager for trips, places, and reservations
- **Collab** — group chat, shared notes, polls, day-by-day attendance
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
- **Journey** — travel journal linking entries to trips, with contributor roles
- **Memories** — photo-focused trip memories
- **Collab**group chat, shared notes, polls, and activity sign-ups
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
- **Journey** magazine-style travel journal with entries, photos (via Immich/Synology Photos), maps, and moods
- **Naver List Import** — import places from shared Naver Maps lists
- **MCP**expose TREK to AI assistants via the Model Context Protocol (OAuth 2.1)
> Dashboard widgets (currency converter and timezone clock) are per-user preferences, not an admin-toggleable addon — see [Dashboard-Widgets](Dashboard-Widgets).
### AI / MCP Integration
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **24 OAuth Scopes** — granular permissions across 13 permission groups
- **150+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **30 Resources** — read-only `trek://` URIs for trips, days, places, budget, packing, journeys, and more
- **27 OAuth Scopes** — granular permissions across 13 permission groups
- **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders
### Admin
@@ -48,7 +54,7 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- Addon management, API key storage, scheduled auto-backups
- System notices for onboarding and announcements
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically.
> **Admin:** Most configuration lives in the Admin Panel. On first boot TREK seeds an admin account automatically — credentials come from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if set, otherwise a random password is printed to the container log.
## Get Started
+5 -5
View File
@@ -93,7 +93,7 @@ ALLOWED_ORIGINS=https://trek.example.com
APP_URL=https://trek.example.com
```
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables].
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables).
## Start TREK
@@ -111,10 +111,10 @@ docker compose logs -f
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
See [Reverse-Proxy] for complete proxy configuration examples.
See [Reverse-Proxy](Reverse-Proxy) for complete proxy configuration examples.
## Next Steps
- [Environment-Variables] — full variable reference
- [Reverse-Proxy] — HTTPS configuration
- [Updating] — how to pull a new image
- [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration
- [Updating](Updating) — how to pull a new image
+6 -6
View File
@@ -32,7 +32,7 @@ Pass additional `-e` flags for timezone and CORS/email link support:
-e ALLOWED_ORIGINS=https://trek.example.com \
```
See [Environment-Variables] for the full list.
See [Environment-Variables](Environment-Variables) for the full list.
## Volume Reference
@@ -66,11 +66,11 @@ docker logs trek
## Limitations of `docker run`
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose](Install-Docker-Compose), which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
## Next Steps
- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
- [Install-Docker-Compose] — recommended for production
- [Environment-Variables] — full list of configurable variables
- [Updating] — how to pull a new image without losing data
- [Reverse-Proxy](Reverse-Proxy) — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
- [Install-Docker-Compose](Install-Docker-Compose) — recommended for production
- [Environment-Variables](Environment-Variables) — full list of configurable variables
- [Updating](Updating) — how to pull a new image without losing data
+2 -2
View File
@@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts
## Next Steps
- [Environment-Variables] — full variable reference
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments
- [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments
+2 -2
View File
@@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are
## Next Steps
- [Environment-Variables] — complete variable reference
- [Updating] — how to pull a new image on Unraid
- [Environment-Variables](Environment-Variables) — complete variable reference
- [Updating](Updating) — how to pull a new image on Unraid
+1 -1
View File
@@ -2,7 +2,7 @@
TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser.
> **Admin:** Two things must be enabled for photo providers to appear in Settings: the **Memories addon** and the **individual photo provider** (Immich or Synology Photos). Both are toggled separately in **Admin → Addons**. See [Admin-Addons](Admin-Addons). If your provider is on a local or private network, the server must be configured to allow internal network access. See [Internal-Network-Access](Internal-Network-Access).
> **Admin:** Enable at least one photo provider (Immich or Synology Photos) in **Admin → Addons** photo provider toggles appear as sub-items under the **Journey** addon. Once a provider is on, a Photo Providers section appears in each user's **Settings → Integrations**. If your provider runs on a local or private network, the server must be configured to allow internal network access. See [Admin-Addons](Admin-Addons) and [Internal-Network-Access](Internal-Network-Access).
---
+4 -4
View File
@@ -60,7 +60,7 @@ You will be prompted to change the password on first login.
## Next Steps
- [Install-Docker-Compose] — production setup with security hardening
- [Reverse-Proxy] — put TREK behind HTTPS (required for PWA install and secure cookies)
- [Environment-Variables] — full configuration reference
- [Admin-Panel-Overview] — explore what the admin panel can do
- [Install-Docker-Compose](Install-Docker-Compose) — production setup with security hardening
- [Reverse-Proxy](Reverse-Proxy) — put TREK behind HTTPS (required for PWA install and secure cookies)
- [Environment-Variables](Environment-Variables) — full configuration reference
- [Admin-Panel-Overview](Admin-Panel-Overview) — explore what the admin panel can do
+3 -3
View File
@@ -98,9 +98,9 @@ Four variables control how TREK behaves behind a proxy. They work as a group:
If you access TREK directly on `http://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`.
See [Environment-Variables] for full documentation of these and all other variables.
See [Environment-Variables](Environment-Variables) for full documentation of these and all other variables.
## Next Steps
- [Environment-Variables] — full variable reference including OIDC
- [Install-Docker-Compose] — production compose file with proxy-ready env vars
- [Environment-Variables](Environment-Variables) — full variable reference including OIDC
- [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars
+20 -1
View File
@@ -1,7 +1,9 @@
# Tags and Categories
TREK has a labeling system: **Global Place Categories** (admin-managed, shared across all users).
TREK has two independent labelling systems for places:
- **Global Place Categories** — admin-managed, shared across every user on the instance (e.g. `Restaurant`, `Museum`).
- **Personal Tags** — user-scoped, private labels (e.g. `hidden gem`, `kid-friendly`).
<!-- TODO: screenshot: tag list on place detail -->
@@ -24,6 +26,23 @@ Categories appear in:
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them.
## Personal Tags
Tags are private labels owned by each user. They attach to individual places via a many-to-many relationship (`place_tags` table), so the same tag can be applied to as many places as you like, and a single place can carry multiple tags.
**Fields per tag:**
- **Name** — free-form text.
- **Color** — hex value displayed alongside the tag name. Default: `#10b981` (emerald).
Tags are scoped to their creator — other trip members do not see your tags, and different users can create tags with identical names without conflict. Deleting a tag automatically removes it from every place it was attached to.
### Where to manage them
At the moment tags are exposed primarily through the MCP API — AI assistants connected to your instance can list, create, update, and delete tags (`list_tags`, `create_tag`, `update_tag`, `delete_tag`) and attach them to places through the place endpoints. A dedicated web UI for tag management is not yet available; the filter `tag` parameter on the places API / MCP resource does support filtering places by a tag ID once one exists.
> **AI / MCP:** See [MCP-Tools-and-Resources](MCP-Tools-and-Resources) for the full tag tool list.
## When to use which
| Use case | Use |
+5 -5
View File
@@ -4,7 +4,7 @@ How to update TREK to a newer version without losing data.
## Before You Update
Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups] for details.
Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups](Backups) for details.
## Docker Compose (Recommended)
@@ -42,7 +42,7 @@ TREK runs any pending database migrations automatically at startup. No manual mi
If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade.
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation] for the full procedure.
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure.
## Unraid
@@ -50,6 +50,6 @@ In the Unraid Docker tab, click the TREK container and select **Update**. Unraid
## Next Steps
- [Backups] — schedule automatic backups so you always have a restore point before updates
- [Encryption-Key-Rotation] — if you need to rotate or migrate the encryption key
- [Install-Docker-Compose] — switch to Compose for easier future updates
- [Backups](Backups) — schedule automatic backups so you always have a restore point before updates
- [Encryption-Key-Rotation](Encryption-Key-Rotation) — if you need to rotate or migrate the encryption key
- [Install-Docker-Compose](Install-Docker-Compose) — switch to Compose for easier future updates