-
-
-
+
+
+
-
-
+
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
@@ -41,61 +40,62 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
-
+
-
-
-
-
-
-
-
-
-
-
-
----
-
## What you get
-
+
+ See all features
+
+
+Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
+
-#### 🧭 Trip planning
+#### 🧭 Planning
-- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
-- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
-- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
-- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
-- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
-- **Route optimisation** — auto-sort places and export to Google Maps
-- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
-- **Category filter** — show only matching pins on the map
+- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
+- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
+- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
+- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
+- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
+- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
+- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
+- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
+- **Public transport**: door-to-door itineraries over Transitous
+- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
+- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
+- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
-#### 🧳 Travel management
+#### 🧳 Bookings and money
-- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
-- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
-- **Packing lists** — categories, templates, user assignment, progress tracking
-- **Bag tracking** — optional weight tracking with iOS-style distribution
-- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
-- **PDF export** — full trip plan as PDF with cover page, images, notes
+- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
+- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
+- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
+- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
+- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
+- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
+- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
+- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
+- **To-dos**: assignee, due date, priority, and a reminder before one falls due
+- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
+- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
@@ -104,64 +104,70 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 👥 Collaboration
-- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
-- **Multi-user trips** — invite members with role-based access
-- **Invite links** — one-time or reusable links with expiry
-- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
-- **2FA** — TOTP + backup codes
-- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
-- **Collab suite** — group chat, shared notes, polls, day check-ins
+- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
+- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
+- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
+- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
+- **Public share**: a read-only trip page anyone can open without an account
+- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
+
+#### 📔 Journal, Atlas and Vacay
+
+- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
+- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
+- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
+- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
-#### 📱 Mobile & PWA
+#### 🧩 Plugins
-- **Installable** — iOS and Android, straight from the browser, no App Store needed
-- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
-- **Native feel** — fullscreen standalone, themed status bar, splash screen
-- **Touch optimised** — mobile-specific layouts with safe-area handling
+- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/`
+- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
+- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
+- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
+- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
+
+#### 🤖 AI and MCP
+
+- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
+- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
+- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
+- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
+- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
+- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
-#### 🧩 Addons (admin-toggleable)
+#### 📱 Mobile and offline
-- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
-- **Costs** — expense tracker with splits and settle-up (who owes whom), 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, approved school holiday overlays, carry-over tracking
-- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
-- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
-- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
-- **MCP** — expose TREK to AI assistants via OAuth 2.1
+- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
+- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
+- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
+- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
+- **Offline maps**: pre-download a trip's raster tiles, and clear them again
+- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
-#### 🤖 AI / MCP
+#### ⚙️ Admin, accounts and security
-- **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
-
-
-
-
-
-
-#### ⚙️ Admin & customisation
-
-- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
-- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
-- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
-- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
-- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
+- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
+- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
+- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
+- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
+- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
+- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, audit log, and GitHub releases
+- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
+- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers
+- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
+- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca
+- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
@@ -171,6 +177,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
+
+
+
+
+
+
+
+
## AI usage
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
@@ -195,7 +209,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 05843d54a..cd643030a 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -1029,7 +1029,8 @@ export const healthApi = {
}
export const weatherApi = {
- get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
+ // `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
+ get: (lat: number, lng: number, date: string, time?: string): Promise => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getCurrent: (lat: number, lng: number, lang?: string): Promise => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx
index 7607acd7a..8f5fa14d5 100644
--- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx
+++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx
@@ -836,4 +836,46 @@ describe('EntryEditor', () => {
expect(amazing.className).toContain('border-zinc-200')
expect(amazing.getAttribute('style')).toBe('')
})
+
+ // #1614 — entry_time was stored, sent and displayed everywhere, but the desktop
+ // editor never rendered an input for it.
+ it('FE-JRN-EDITOR-041: shows the stored time and saves an edited one', async () => {
+ const user = userEvent.setup()
+ const { onSave } = mountEditor(buildEntry({ id: 10, title: 'Old', entry_time: '14:30:00' }))
+
+ // The column carries HH:MM:SS; a time input only accepts HH:MM.
+ const timeInput = screen.getByDisplayValue('14:30')
+ expect(timeInput).toHaveAttribute('type', 'time')
+
+ fireEvent.change(timeInput, { target: { value: '09:05' } })
+ await user.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() => expect(onSave).toHaveBeenCalled())
+ expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: '09:05' }))
+ })
+
+ it('FE-JRN-EDITOR-042: clearing the time sends null rather than an empty string', async () => {
+ const user = userEvent.setup()
+ const { onSave } = mountEditor(buildEntry({ id: 10, entry_time: '14:30:00' }))
+
+ fireEvent.change(screen.getByDisplayValue('14:30'), { target: { value: '' } })
+ await user.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() => expect(onSave).toHaveBeenCalled())
+ expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
+ })
+
+ it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
+ const { container } = mountEditor(buildEntry({ id: 10 }))
+
+ const inputs = Array.from(container.querySelectorAll('input[type="file"]'))
+ const camera = inputs.filter(i => i.getAttribute('capture') === 'environment')
+ const library = inputs.filter(i => !i.hasAttribute('capture'))
+
+ expect(camera).toHaveLength(1)
+ expect(library).toHaveLength(1)
+ // The library picker keeps multi-select; forcing capture onto it would drop that.
+ expect(library[0]).toHaveAttribute('multiple')
+ expect(camera[0]).not.toHaveAttribute('multiple')
+ })
})
diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx
index 46e2f188b..7cba4e956 100644
--- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx
+++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'
-import { X, Plus, Image, Minus, Check, MapPin, Locate } from 'lucide-react'
+import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
@@ -34,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
- const [entryTime, setEntryTime] = useState(entry.entry_time || '')
+ const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
const [locationName, setLocationName] = useState(entry.location_name || '')
const [locationLat, setLocationLat] = useState(entry.location_lat ?? null)
const [locationLng, setLocationLng] = useState(entry.location_lng ?? null)
@@ -60,6 +60,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [externalProvider, setExternalProvider] = useState(null)
const [pendingProviderGroups, setPendingProviderGroups] = useState([])
const fileRef = useRef(null)
+ // Own input: putting `capture` on the picker above would take the photo library
+ // away on a phone, which is the more common way in. This one only ever opens the
+ // camera, so tablets and laptops get the same route the phone sheet already has.
+ const cameraRef = useRef(null)
const storyRef = useRef(null)
const persistedEntryIdRef = useRef(entry.id > 0 ? entry.id : null)
@@ -71,7 +75,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
- entryTime !== (entry.entry_time || '') ||
+ entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
locationName !== (entry.location_name || '') ||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
@@ -258,6 +262,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{ (e.target as HTMLInputElement).value = '' }} className="hidden" />
+ { (e.target as HTMLInputElement).value = '' }} className="hidden" />
)}
+
-
-
-
+ {/* Time sat in state and went to the server, it just had no input here — so a
+ draft's auto-stamped clock time showed up in the timeline, the map, the PDF
+ and the public share, and the desktop had no way to correct it (#1614). */}
+
)}
+ {/* Quick capture keeps a note field: the ask was "photos *or* quick notes"
+ unterwegs, and a moment without a picture is still worth catching. Two
+ rows rather than three — it is a note, not the story, which is written
+ later in the full sheet. */}
+ {captureOnly && !readOnly && (
+
+ )}
+
{/* ── Notes ── */}
{res.notes && (
<>
diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx
index 62f3373c8..71a5937b7 100644
--- a/client/src/pages/JourneyPublicPage.test.tsx
+++ b/client/src/pages/JourneyPublicPage.test.tsx
@@ -536,4 +536,48 @@ describe('JourneyPublicPage', () => {
expect(images.length).toBeGreaterThan(0);
});
});
+
+ // #1962 — the marker number used to be the stop's position within its day while
+ // the day heading showed the day number, so the same digit appeared repeatedly in
+ // different colours with no key. Both now count stops across the whole journey.
+ it('FE-PAGE-PUBLICJOURNEY-021: numbers each stop once, and the timeline carries the same number', async () => {
+ setupSuccess();
+ render();
+
+ await waitFor(() => expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument());
+
+ // Two geocoded entries on two different days: stops 1 and 2, not 1 and 1.
+ const shibuya = document.querySelector('[data-entry-id="10"]') as HTMLElement;
+ const sensoji = document.querySelector('[data-entry-id="11"]') as HTMLElement;
+ expect(shibuya).toBeTruthy();
+ expect(sensoji).toBeTruthy();
+
+ const badge = (el: HTMLElement) =>
+ Array.from(el.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || ''))?.textContent;
+
+ expect(badge(shibuya)).toBe('1');
+ expect(badge(sensoji)).toBe('2');
+ });
+
+ // #1614 — a journey shared mid-trip reads like a blog. Only the reading order
+ // flips; the stop numbers stay chronological so the map still matches.
+ it('FE-PAGE-PUBLICJOURNEY-022: the reader can flip the order without renumbering the trip', async () => {
+ setupSuccess();
+ render();
+
+ await waitFor(() => expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument());
+
+ const order = () =>
+ Array.from(document.querySelectorAll('[data-entry-id]')).map(el => el.getAttribute('data-entry-id'));
+ expect(order()).toEqual(['10', '11']);
+
+ fireEvent.click(screen.getByRole('button', { name: /first/i }));
+
+ await waitFor(() => expect(order()).toEqual(['11', '10']));
+
+ // Numbering is unchanged: entry 10 is still stop 1 even though it is shown last.
+ const shibuya = document.querySelector('[data-entry-id="10"]') as HTMLElement;
+ const badge = Array.from(shibuya.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || ''));
+ expect(badge?.textContent).toBe('1');
+ });
});
diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx
index e6c8b55f9..e75a976ec 100644
--- a/client/src/pages/JourneyPublicPage.tsx
+++ b/client/src/pages/JourneyPublicPage.tsx
@@ -5,6 +5,7 @@ import {
Cloud,
CloudLightning,
CloudRain,
+ ArrowUpDown,
CloudSun,
Frown,
Grid,
@@ -110,8 +111,13 @@ export default function JourneyPublicPage() {
timelineEntries,
groupedEntries,
sortedDates,
+ displayDates,
+ newestFirst,
+ setNewestFirst,
sidebarMapItems,
allPhotos,
+ stopNumberById,
+ mapPhotos,
desktopTwoColumn,
} = useJourneyPublic();
@@ -143,15 +149,44 @@ export default function JourneyPublicPage() {
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[];
+ // The hook cannot build these: the thumbnail URL needs the share token, which is
+ // the credential for the byte proxy.
+ const photoLayer = token
+ ? mapPhotos.map((p: { id: string; lat: number; lng: number; photoId: number }) => ({
+ id: p.id,
+ lat: p.lat,
+ lng: p.lng,
+ thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'),
+ }))
+ : [];
+
// Shared timeline renderer used in both layout modes
const renderTimeline = () => (
{sortedDates.length === 0 && (
)}
- {sortedDates.map((date, dayIdx) => {
+ {/* The owner publishes a reading order; the reader may flip it. Only the order
+ changes — the day colours and the stop numbers stay chronological. */}
+ {sortedDates.length > 1 && (
+
+
+
+ )}
+ {displayDates.map((date) => {
const dayEntries = groupedEntries.get(date)!;
const fd = formatDate(date, locale);
+ // Day index stays chronological even when the feed reads newest-first, so a
+ // day keeps its colour and its number whichever way round it is shown.
+ const dayIdx = sortedDates.indexOf(date);
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length];
return (
@@ -304,6 +339,22 @@ export default function JourneyPublicPage() {
)}
+ {/* The number the map marker carries, so the timeline is the key
+ to the map rather than a second, unrelated numbering (#1962).
+ Outside the photo-count gate below: a single-photo entry puts
+ its title and location in the image overlay, but the stop
+ number has to be readable either way. */}
+ {stopNumberById.has(String(entry.id)) && (
+
,
useMap: () => ({
fitBounds: vi.fn(),
@@ -500,8 +502,11 @@ describe('SharedTripPage', () => {
);
renderSharedTrip('test-token');
// The untitled day shows the German label "Tag 1", proving the hardcoded English
- // "Day 1" was replaced by the i18n key t('dayplan.dayN').
- await waitFor(() => expect(screen.getByText('Tag 1')).toBeInTheDocument());
+ // "Day 1" was replaced by the i18n key t('dayplan.dayN'). It appears twice since
+ // the day picker above the map was added (#1962): once as a chip, once on the card.
+ await waitFor(() => expect(screen.getAllByText('Tag 1')).toHaveLength(2));
+ const labels = screen.getAllByText('Tag 1');
+ expect(labels.filter((el) => el.closest('button'))).toHaveLength(1);
});
});
@@ -568,7 +573,7 @@ describe('SharedTripPage', () => {
});
});
- // FE-PAGE-SHARED-021 to FE-PAGE-SHARED-036 drive the remaining render branches of
+ // FE-PAGE-SHARED-021 to FE-PAGE-SHARED-037 drive the remaining render branches of
// the page: header variants, the permission-driven tab strip, and every item kind
// the day timeline, bookings, packing, costs and chat sections can produce.
@@ -701,7 +706,10 @@ describe('SharedTripPage', () => {
accommodations: [{ id: 3, place_name: 'Hotel Lutetia', start_day_id: 5, end_day_id: 5 }],
}));
- expect(screen.getByText('Day 1')).toBeInTheDocument();
+ // Twice since the day picker landed (#1962): the chip above the map and the card.
+ const dayLabels = screen.getAllByText('Day 1');
+ expect(dayLabels).toHaveLength(2);
+ expect(dayLabels.filter((el) => el.closest('button'))).toHaveLength(1);
expect(screen.getByText('Hotel Lutetia')).toBeInTheDocument();
expect(screen.getByText('0 places')).toBeInTheDocument();
// No date row for an undated day.
@@ -785,6 +793,158 @@ describe('SharedTripPage', () => {
});
});
+ // ── Day order, route line and the day picker at the map (#1962) ────────────
+ describe('FE-PAGE-SHARED-038: day markers carry their order and a connecting line', () => {
+ const day = { id: 7, trip_id: 1, day_number: 1, date: '2026-07-02', title: 'Day One' };
+ const louvre = { id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category: { id: 1, name: 'Sight', color: '#ff0000', icon: 'landmark' } };
+ const orsay = { id: 202, name: 'Orsay', lat: 48.85, lng: 2.32, category: null };
+ const notre = { id: 203, name: 'Notre-Dame', lat: 48.853, lng: 2.35, category: null };
+
+ // Every divIcon call the last render produced, as raw html strings.
+ const iconHtml = () => (L.divIcon as unknown as ReturnType).mock.calls.map((c: any[]) => String(c[0].html));
+
+ beforeEach(() => (L.divIcon as unknown as ReturnType).mockClear());
+
+ it('numbers the stops by order_index, not by payload order', async () => {
+ await open('order-map-token', payload({
+ days: [day],
+ places: [louvre, orsay, notre],
+ assignments: {
+ '7': [
+ { id: 302, day_id: 7, place_id: 202, order_index: 1, place: orsay },
+ { id: 303, day_id: 7, place_id: 203, order_index: 2, place: notre },
+ { id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre },
+ ],
+ },
+ }));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
+ await waitFor(() => expect(iconHtml().some((h: string) => h.includes('>1<'))).toBe(true));
+
+ const html = iconHtml();
+ // Louvre is order_index 0 and therefore 1, regardless of where it sat in the payload.
+ expect(html.filter((h: string) => h.includes('>1<'))).toHaveLength(1);
+ expect(html.filter((h: string) => h.includes('>2<'))).toHaveLength(1);
+ expect(html.filter((h: string) => h.includes('>3<'))).toHaveLength(1);
+ });
+
+ it('shows both positions once for a stop the day visits twice, and renders it once', async () => {
+ await open('twice-token', payload({
+ days: [day],
+ places: [louvre, orsay],
+ assignments: {
+ '7': [
+ { id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre },
+ { id: 302, day_id: 7, place_id: 202, order_index: 1, place: orsay },
+ { id: 303, day_id: 7, place_id: 201, order_index: 2, place: louvre },
+ ],
+ },
+ }));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
+ await waitFor(() => expect(iconHtml().some((h: string) => h.includes('1 \u00b7 3'))).toBe(true));
+ // One marker for the repeated place, not two with the same React key.
+ expect(iconHtml().filter((h: string) => h.includes('1 \u00b7 3'))).toHaveLength(1);
+ });
+
+ it('leaves the trip-wide pool unnumbered, since it has no order to show', async () => {
+ await open('nonum-token', payload({
+ days: [day],
+ places: [louvre, orsay],
+ assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] },
+ }));
+
+ await waitFor(() => expect(iconHtml().length).toBeGreaterThan(0));
+ expect(iconHtml().some((h: string) => h.includes('border-radius:8px'))).toBe(false);
+ });
+
+ it('draws the connecting line only for a day with more than one stop', async () => {
+ await open('line-token', payload({
+ days: [day],
+ places: [louvre, orsay],
+ assignments: {
+ '7': [
+ { id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre },
+ { id: 302, day_id: 7, place_id: 202, order_index: 1, place: orsay },
+ ],
+ },
+ }));
+
+ // No day selected: no line, even though the trip has two geocoded places.
+ expect(screen.queryByTestId('route-line')).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
+ await waitFor(() => expect(screen.getByTestId('route-line')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'All' }));
+ await waitFor(() => expect(screen.queryByTestId('route-line')).toBeNull());
+ });
+
+ it('does not draw a line for a day with a single stop', async () => {
+ await open('single-token', payload({
+ days: [day],
+ places: [louvre],
+ assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] },
+ }));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
+ await waitFor(() => expect(screen.getAllByText('Louvre').length).toBeGreaterThan(1));
+ expect(screen.queryByTestId('route-line')).toBeNull();
+ });
+ });
+
+ describe('FE-PAGE-SHARED-039: the day picker sits at the map and agrees with the day card', () => {
+ const day = { id: 7, trip_id: 1, day_number: 1, date: '2026-07-02', title: 'Day One' };
+ const louvre = { id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category: null };
+ const orsay = { id: 202, name: 'Orsay', lat: 48.85, lng: 2.32, category: null };
+
+ it('narrows the markers to one day and back again', async () => {
+ await open('picker-token', payload({
+ days: [day],
+ places: [louvre, orsay],
+ assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] },
+ }));
+
+ // All: both geocoded places are on the map, so Orsay's tooltip is present.
+ expect(screen.getByText('Orsay')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
+ await waitFor(() => expect(screen.queryByText('Orsay')).toBeNull());
+
+ fireEvent.click(screen.getByRole('button', { name: 'All' }));
+ await waitFor(() => expect(screen.getByText('Orsay')).toBeInTheDocument());
+ });
+
+ it('marks the active chip for assistive tech', async () => {
+ await open('pressed-token', payload({ days: [day], places: [louvre], assignments: {} }));
+
+ expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true');
+ fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Day 1' })).toHaveAttribute('aria-pressed', 'true'),
+ );
+ expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false');
+ });
+ });
+
+ describe('FE-PAGE-SHARED-040: trip-wide markers keep their category colour', () => {
+ // The payload nests the category on a day's assignments but sends it flat on the
+ // trip-wide pool, so reading only the nested shape painted every marker indigo.
+ it('reads the flat category_color the trip pool sends', async () => {
+ (L.divIcon as unknown as ReturnType).mockClear();
+ await open('flatcat-token', payload({
+ days: [],
+ places: [{ id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category_color: '#ff8800', category_icon: 'landmark' }],
+ assignments: {},
+ }));
+
+ await waitFor(() => expect((L.divIcon as unknown as ReturnType).mock.calls.length).toBeGreaterThan(0));
+ const html = (L.divIcon as unknown as ReturnType).mock.calls.map((c: any[]) => String(c[0].html));
+ expect(html.some((h: string) => h.includes('#ff8800'))).toBe(true);
+ expect(html.some((h: string) => h.includes('#6366f1'))).toBe(false);
+ });
+ });
+
describe('FE-PAGE-SHARED-026: expanded day renders notes and single-leg transports', () => {
const day = { id: 9, trip_id: 1, day_number: 1, date: '2026-07-02', title: 'Day One' };
@@ -1134,4 +1294,34 @@ describe('SharedTripPage', () => {
expect(option.style.background).toBe('none');
});
});
+
+ describe('FE-PAGE-SHARED-037: a multi-day parking only shows on its drop-off and pickup day (#1937)', () => {
+ const days = [
+ { id: 9, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' },
+ { id: 10, trip_id: 1, day_number: 2, date: '2026-07-02', title: 'Day Two' },
+ { id: 11, trip_id: 1, day_number: 3, date: '2026-07-03', title: 'Day Three' },
+ ];
+
+ it('skips the days in between and leaves that day collapsed', async () => {
+ await open('parking-token', payload({
+ days,
+ reservations: [
+ {
+ id: 81, title: 'Airport Parking', type: 'parking', status: 'confirmed', day_id: 9, end_day_id: 11,
+ reservation_time: '2026-07-01T05:30:00', reservation_end_time: '2026-07-03T19:00:00', metadata: null,
+ },
+ ],
+ }));
+
+ // Only one day is expanded at a time, so each day is checked on its own.
+ fireEvent.click(screen.getByText('Day One'));
+ await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument());
+
+ fireEvent.click(screen.getByText('Day Two'));
+ await waitFor(() => expect(screen.queryByText(/Airport Parking/)).toBeNull());
+
+ fireEvent.click(screen.getByText('Day Three'));
+ await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument());
+ });
+ });
});
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx
index d8ca68722..6c8cbf235 100644
--- a/client/src/pages/SharedTripPage.tsx
+++ b/client/src/pages/SharedTripPage.tsx
@@ -17,14 +17,14 @@ import {
} from 'lucide-react';
import { createElement, useEffect, useRef } from 'react';
import { renderIconMarkup } from '../utils/iconMarkup';
-import { MapContainer, Marker, TileLayer, Tooltip, useMap } from 'react-leaflet';
+import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet';
import { getCategoryIcon } from '../components/shared/categoryIcons';
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../constants/mapDefaults';
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
import { useSettingsStore } from '../store/settingsStore';
import { avatarSrc } from '../utils/avatarSrc';
import { safeHexColor } from '../utils/safeColor';
-import { getMergedItems, getTransportForDay } from '../utils/dayMerge';
+import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge';
import { isDayInAccommodationRange } from '../utils/dayOrder';
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
import { splitReservationDateTime } from '../utils/formatters';
@@ -33,24 +33,41 @@ import { useSharedTrip } from './sharedTrip/useSharedTrip';
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
-function createMarkerIcon(place: any) {
+// Injected into Leaflet's marker HTML, where CSS variables cannot reach - the same
+// reason MapView.tsx is exempt from theme:lint outright.
+const ORDER_BADGE_STYLE = 'position:absolute;bottom:-4px;right:-4px;min-width:16px;height:16px;border-radius:8px;padding:0 3px;background:rgba(255,255,255,0.94);border:1.5px solid rgba(0,0,0,0.15);box-shadow:0 1px 4px rgba(0,0,0,0.18);display:flex;align-items:center;justify-content:center;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;white-space:nowrap;'; // theme-lint-disable
+
+function createMarkerIcon(place: any, orderNumbers?: number[] | null) {
const cat = place.category;
// This page answers without a guard, so an unescaped colour here reaches
// people who have no account on the instance at all.
- const color = safeHexColor(cat?.color, '#6366f1');
- const CatIcon = getCategoryIcon(cat?.icon);
+ // The payload carries the category in two shapes: nested on a day's assignments,
+ // flat on the trip-wide pool. Reading only the nested one dropped every marker
+ // outside a day selection to the placeholder colour.
+ const color = safeHexColor(cat?.color ?? place.category_color, '#6366f1');
+ const CatIcon = getCategoryIcon(cat?.icon ?? place.category_icon);
const iconSvg = renderIconMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' }));
+ // Position in the day's order, same contract as the planner: a stop the day
+ // visits twice shows both, e.g. "1 . 3". The values are array indices, never payload.
+ const badge = orderNumbers?.length
+ ? `${orderNumbers.join(' \u00b7 ')}`
+ : '';
return L.divIcon({
className: '',
iconSize: [28, 28],
iconAnchor: [14, 14],
- html: `
${iconSvg}
`,
+ html: `
${iconSvg}${badge}
`,
});
}
function FitBoundsToPlaces({ places, framedOnMount }: { places: any[]; framedOnMount: boolean }) {
const map = useMap();
const fitRan = useRef(false);
+ // The page rebuilds this array on every render (a Page body may not memoise), so
+ // keying the effect on the coordinates rather than the array identity is what stops
+ // an unrelated re-render - the language picker, a late FX response - from throwing
+ // the viewer's pan and zoom away.
+ const fitKey = places.map((p) => `${p.lat},${p.lng}`).join('|');
useEffect(() => {
if (places.length === 0) return;
// The map already opened framed on these places; fitting again would only re-do it.
@@ -62,7 +79,7 @@ function FitBoundsToPlaces({ places, framedOnMount }: { places: any[]; framedOnM
fitRan.current = true;
const bounds = L.latLngBounds(places.map((p) => [p.lat, p.lng]));
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
- }, [places, map]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [fitKey, map]); // eslint-disable-line react-hooks/exhaustive-deps
return null;
}
@@ -136,10 +153,27 @@ export default function SharedTripPage() {
} = data;
const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number);
- // Map places
- const mapPlaces = selectedDay
- ? (assignments[String(selectedDay)] || []).map((a: any) => a.place).filter((p: any) => p?.lat && p?.lng)
- : (places || []).filter((p: any) => p?.lat && p?.lng);
+ // Map places. In day mode each stop carries its position in the day's order; the
+ // trip-wide pool has none (it arrives by created_at). The index runs over the full
+ // sorted assignment list, like the planner does, so a stop without coordinates still
+ // consumes a number and the app and the share link agree on what "3" means.
+ const dayAssignments = selectedDay
+ ? [...(assignments[String(selectedDay)] || [])].sort((a: any, b: any) => a.order_index - b.order_index)
+ : [];
+ const dayOrderMap: Record = {};
+ dayAssignments.forEach((a: any, i: number) => {
+ if (!a.place?.id) return;
+ (dayOrderMap[a.place.id] ||= []).push(i + 1);
+ });
+ const dayPlaces: any[] = [];
+ const seenPlaceIds = new Set();
+ for (const a of dayAssignments as any[]) {
+ const p = a.place;
+ if (!p?.lat || !p?.lng || seenPlaceIds.has(p.id)) continue;
+ seenPlaceIds.add(p.id);
+ dayPlaces.push(p);
+ }
+ const mapPlaces = selectedDay ? dayPlaces : (places || []).filter((p: any) => p?.lat && p?.lng);
// Open framed on the trip's places instead of on Paris. MapContainer only reads center/zoom
// at mount, so recomputing this per render is free — and the fit below takes over from there.
@@ -396,6 +430,41 @@ export default function SharedTripPage() {
{/* Map */}
{activeTab === 'plan' && (
<>
+ {/* Day picker at the map. Same setter as the day card below, so the map and
+ the expanded day can never disagree. Without it the only way to narrow the
+ map down was a control 300px further down the page. */}
+ {sortedDays.length > 0 && (
+
+ )}
+ {selectedDay && mapPlaces.length > 1 && (
+ [p.lat, p.lng])}
+ // Dashed and straight on purpose: it shows the order of the day's stops,
+ // not the roads between them. A real route would mean sending the
+ // itinerary to a third party for every anonymous visitor of a shared
+ // link, with no way for the trip's owner to opt out.
+ pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.8, dashArray: '6 8', lineCap: 'round' }} // theme-lint-disable
+ interactive={false}
+ />
+ )}
{mapPlaces.map((p: any) => (
-
+ {p.name}
))}
@@ -443,12 +523,16 @@ export default function SharedTripPage() {
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays)
);
+ // The shared link has to say what the app says: a multi-day parking only
+ // shows up on its drop-off and pickup day (#1937). Filtered here rather
+ // than skipped in the loop below so the day body isn't gated open on a
+ // row that never renders.
const merged = getMergedItems({
dayAssignments: da,
dayNotes: notes,
dayTransports: dayTransport,
dayId: day.id,
- });
+ }).filter(item => !(item.type === 'transport' && hidesOnMiddleDay(item.data, day.id)));
return (
{di + 1}
-
+ {/* `flex: 1` alone gives this block a base size of 0, so the moment an
+ accommodation chip pushes the row over the available width it freezes
+ at its min-content width — a ~37px column with one word, or one CJK
+ character, per line (#1955). Base auto plus minWidth 0 lets it shrink
+ proportionally and truncate instead. */}
+
diff --git a/client/src/pages/atlas/atlasModel.test.ts b/client/src/pages/atlas/atlasModel.test.ts
index e7c1c927a..4e02c96af 100644
--- a/client/src/pages/atlas/atlasModel.test.ts
+++ b/client/src/pages/atlas/atlasModel.test.ts
@@ -6,10 +6,12 @@ import {
isBucketDuplicateError,
isCountryVisible,
normalizeRegionName,
+ regionCacheEvictions,
withCountryMarkedVisited,
wishlistA3Codes,
countryColor,
COUNTRY_COLORS,
+ REGION_CACHE_MAX,
type AtlasData,
type BucketItem,
} from './atlasModel';
@@ -260,6 +262,32 @@ describe('findBucketDuplicate (#1898)', () => {
});
});
+// The region cache used to grow for the whole session (#1950). The eviction order is
+// pure arithmetic, so it is decided here rather than inside the Leaflet effect.
+describe('regionCacheEvictions (#1950)', () => {
+ it('drops nothing while the cache is within its cap', () => {
+ expect(regionCacheEvictions(['FR', 'IT'], new Set(), REGION_CACHE_MAX)).toEqual([]);
+ expect(regionCacheEvictions(['FR', 'IT'], new Set(), 2)).toEqual([]);
+ });
+
+ it('drops the least recently viewed countries first, and only as many as needed', () => {
+ expect(regionCacheEvictions(['FR', 'IT', 'ES', 'PT'], new Set(), 2)).toEqual(['FR', 'IT']);
+ expect(regionCacheEvictions(['FR', 'IT', 'ES', 'PT'], new Set(), 3)).toEqual(['FR']);
+ });
+
+ it('never drops a country that has to stay', () => {
+ // FR is the oldest entry but still on screen, so the next-oldest ones go instead.
+ expect(regionCacheEvictions(['FR', 'IT', 'ES', 'PT'], new Set(['FR']), 2)).toEqual(['IT', 'ES']);
+ });
+
+ it('stays over the cap rather than dropping what is in view', () => {
+ // Three countries on screen with room for two: evicting one would only make the
+ // next pan fetch it straight back.
+ expect(regionCacheEvictions(['FR', 'IT', 'ES'], new Set(['FR', 'IT', 'ES']), 2)).toEqual([]);
+ expect(regionCacheEvictions(['FR', 'IT', 'ES', 'PT'], new Set(['IT', 'ES', 'PT']), 2)).toEqual(['FR']);
+ });
+});
+
describe('isBucketDuplicateError (#1898)', () => {
it('recognises the 409 and nothing else', () => {
expect(isBucketDuplicateError({ response: { status: 409 } })).toBe(true);
diff --git a/client/src/pages/atlas/atlasModel.ts b/client/src/pages/atlas/atlasModel.ts
index 7bd5a1d5e..a806b29c6 100644
--- a/client/src/pages/atlas/atlasModel.ts
+++ b/client/src/pages/atlas/atlasModel.ts
@@ -184,6 +184,33 @@ export function countryColor(a3: string): string {
return COUNTRY_COLORS[hash % COUNTRY_COLORS.length]
}
+// How many countries' worth of admin-1 geometry the region layer keeps around.
+// Panning back to a country you just left has to stay free, but a session that
+// wanders across a continent used to hold on to every country it ever touched,
+// a few hundred polygons each (#1950). A dozen covers the countries around any
+// one view, and /regions/geo is served with a day of cache headroom, so the
+// countries that do fall out come back without hitting the database.
+export const REGION_CACHE_MAX = 12
+
+/**
+ * Which cached countries to drop, given the cache order (least recently viewed
+ * first) and the codes that have to stay. Never returns a code from `keep`: when
+ * the countries in view alone exceed the cap it returns a shorter list instead of
+ * dropping geometry that is on screen, which is what stops panning across a big
+ * country turning into fetch / evict / refetch.
+ */
+export function regionCacheEvictions(order: string[], keep: Set, max: number): string[] {
+ const drop: string[] = []
+ let remaining = order.length
+ for (const code of order) {
+ if (remaining <= max) break
+ if (keep.has(code)) continue
+ drop.push(code)
+ remaining -= 1
+ }
+ return drop
+}
+
// Convert country code to flag emoji
export function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
diff --git a/client/src/pages/atlas/useAtlas.test.tsx b/client/src/pages/atlas/useAtlas.test.tsx
index a575a0dd1..43bd31c69 100644
--- a/client/src/pages/atlas/useAtlas.test.tsx
+++ b/client/src/pages/atlas/useAtlas.test.tsx
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildSettings } from '../../../tests/helpers/factories';
import { useSettingsStore } from '../../store/settingsStore';
+import L from 'leaflet';
import { A2_TO_A3 } from './atlasModel';
import { useAtlas } from './useAtlas';
@@ -16,6 +17,8 @@ interface MockGeoJson {
options: Record;
entries: { feature: Record; layer: MockLayer }[];
styles: Record[];
+ /** Whether this layer is on the map right now: built is not the same as shown. */
+ attached: boolean;
}
interface MockLayer {
@@ -32,6 +35,9 @@ const lf = vi.hoisted(() => ({
panes: {} as Record }>,
zoom: 3,
intersects: true,
+ // The country codes the viewport is over, when a test needs to be selective about it.
+ // null falls back to the blanket `intersects` answer.
+ intersectsOnly: null as string[] | null,
hasLayer: true,
boundsThrows: false,
mapsCreated: 0,
@@ -44,6 +50,7 @@ const lf = vi.hoisted(() => ({
this.panes = { overlayPane: { style: {} } };
this.zoom = 3;
this.intersects = true;
+ this.intersectsOnly = null;
this.hasLayer = true;
this.boundsThrows = false;
this.mapsCreated = 0;
@@ -53,7 +60,10 @@ const lf = vi.hoisted(() => ({
}));
vi.mock('leaflet', () => {
- const makeLayer = () => {
+ // The bounds a country layer reports carry its own code, so the map's bounds can
+ // answer `intersects` per country instead of all-or-nothing.
+ const makeLayer = (feature?: { properties?: Record }) => {
+ const code = typeof feature?.properties?.ISO_A2 === 'string' ? feature.properties.ISO_A2 : null;
const handlers: Record void> = {};
const layer = {
handlers,
@@ -62,7 +72,7 @@ vi.mock('leaflet', () => {
setStyle: vi.fn(),
getBounds: vi.fn(() => {
if (lf.boundsThrows) throw new Error('no bounds');
- return { isValid: () => true };
+ return { isValid: () => true, code };
}),
};
return layer;
@@ -75,10 +85,16 @@ vi.mock('leaflet', () => {
remove: vi.fn(() => { lf.mapsRemoved += 1; }),
fitBounds: vi.fn(),
addLayer: vi.fn(),
- removeLayer: vi.fn(),
+ removeLayer: vi.fn((layer?: { record?: { attached: boolean } }) => {
+ if (layer?.record) layer.record.attached = false;
+ }),
getZoom: vi.fn(() => lf.zoom),
getCenter: vi.fn(() => ({ lat: 25, lng: 0 })),
- getBounds: vi.fn(() => ({ intersects: () => lf.intersects })),
+ getBounds: vi.fn(() => ({
+ intersects: (other?: { code?: string | null }) => (
+ lf.intersectsOnly ? !!other?.code && lf.intersectsOnly.includes(other.code) : lf.intersects
+ ),
+ })),
hasLayer: vi.fn(() => lf.hasLayer),
createPane: vi.fn((name: string) => { lf.panes[name] = { style: {} }; }),
getPane: vi.fn((name: string) => lf.panes[name]),
@@ -100,22 +116,23 @@ vi.mock('leaflet', () => {
}),
layerGroup: vi.fn(() => ({ addTo: vi.fn(() => ({})) })),
geoJSON: vi.fn((data: { features?: Record[] }, options: Record) => {
- const record = { data, options, entries: [] as unknown[], styles: [] as unknown[] };
+ const record = { data, options, entries: [] as unknown[], styles: [] as unknown[], attached: false };
const style = options?.style as ((f: unknown) => unknown) | undefined;
const onEachFeature = options?.onEachFeature as ((f: unknown, l: unknown) => void) | undefined;
for (const feature of data?.features ?? []) {
if (style) record.styles.push(style(feature));
if (onEachFeature) {
- const layer = makeLayer();
+ const layer = makeLayer(feature as { properties?: Record });
onEachFeature(feature, layer);
record.entries.push({ feature, layer });
}
}
lf.geoJson.push(record);
const result = {
- addTo: vi.fn(() => result),
- removeFrom: vi.fn(),
- remove: vi.fn(),
+ record,
+ addTo: vi.fn(() => { record.attached = true; return result; }),
+ removeFrom: vi.fn(() => { record.attached = false; }),
+ remove: vi.fn(() => { record.attached = false; }),
clearLayers: vi.fn(),
resetStyle: vi.fn(),
};
@@ -196,6 +213,20 @@ function countryLayers(): MockGeoJson[] {
return (lf.geoJson as MockGeoJson[]).filter((g) => g.options.pane === undefined);
}
+/** Every region layer ever built, oldest first: one record per rebuild. */
+function regionLayers(): MockGeoJson[] {
+ return (lf.geoJson as MockGeoJson[]).filter((g) => g.options.pane === 'regionPane');
+}
+
+function newestRegionLayer(): MockGeoJson | undefined {
+ const layers = regionLayers();
+ return layers[layers.length - 1];
+}
+
+function regionCodesOf(layer: MockGeoJson | undefined): string[] {
+ return (layer?.data.features ?? []).map((f) => (f.properties as Record).iso_3166_2);
+}
+
async function mountAtlas(over: Partial> = {}, props: { withPanel?: boolean } = {}) {
useAtlasHandlers(over);
const view = render();
@@ -808,6 +839,188 @@ describe('useAtlas', () => {
expect((lf.geoJson as MockGeoJson[]).some((g) => g.options.pane === 'regionPane')).toBe(false);
});
+
+ // ── The region layer used to keep every country it had ever seen and rebuild all of
+ // it, with a fresh renderer each time (#1950) ────────────────────────────────────
+ const twoRegions = {
+ type: 'FeatureCollection',
+ features: [
+ feature({ iso_a2: 'fr', iso_3166_2: 'FR-BRE', name: 'Bretagne', name_en: 'Brittany', admin: 'France' }),
+ feature({ iso_a2: 'it', iso_3166_2: 'IT-62', name: 'Lazio', name_en: 'Latium', admin: 'Italy' }),
+ ],
+ };
+
+ /** Zoom in far enough for regions, over the given countries. */
+ const zoomTo = async (...codes: string[]) => {
+ lf.zoom = 6;
+ lf.intersectsOnly = codes;
+ await act(async () => { lf.mapHandlers.zoomend?.forEach((cb) => cb()); });
+ };
+
+ const panTo = async (...codes: string[]) => {
+ lf.intersectsOnly = codes;
+ await act(async () => { lf.mapHandlers.moveend?.forEach((cb) => cb()); });
+ };
+
+ it('FE-HOOK-ATLAS-038: a map builds one canvas and one svg renderer for its whole life (#1950)', async () => {
+ // Leaflet keeps a renderer as a layer of its own and leaves its container in the
+ // pane when the GeoJSON layer goes, so one per redraw piles up orphaned canvases
+ // that keep updating on every pan.
+ const canvasBefore = vi.mocked(L.canvas).mock.calls.length;
+ const svgBefore = vi.mocked(L.svg).mock.calls.length;
+ await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
+ await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
+
+ await zoomTo('FR');
+ await waitFor(() => expect(regionLayers().length).toBe(1));
+ // A second country lands, then the planned toggle repaints both layers.
+ await panTo('FR', 'IT');
+ await waitFor(() => expect(regionLayers().length).toBe(2));
+ act(() => atlas.togglePlanned());
+ await waitFor(() => expect(regionLayers().length).toBe(3));
+
+ expect(lf.mapsCreated).toBe(1);
+ expect(vi.mocked(L.canvas).mock.calls.length).toBe(canvasBefore + 1);
+ expect(vi.mocked(L.svg).mock.calls.length).toBe(svgBefore + 1);
+ });
+
+ it('FE-HOOK-ATLAS-039: only the countries in view contribute features (#1950)', async () => {
+ await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
+ await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
+
+ await zoomTo('FR', 'IT');
+ await waitFor(() => expect(regionCodesOf(newestRegionLayer())).toEqual(['FR-BRE', 'IT-62']));
+
+ // Panning off Italy leaves it in the cache but takes it out of the layer.
+ await panTo('FR');
+ expect(regionCodesOf(newestRegionLayer())).toEqual(['FR-BRE']);
+
+ // Coming back draws it again straight from the cache.
+ await panTo('FR', 'IT');
+ expect(regionCodesOf(newestRegionLayer())).toEqual(['FR-BRE', 'IT-62']);
+ });
+
+ it('FE-HOOK-ATLAS-040: panning inside the same countries rebuilds nothing (#1950)', async () => {
+ await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
+ await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
+
+ await zoomTo('FR', 'IT');
+ await waitFor(() => expect(regionLayers().length).toBe(1));
+
+ await panTo('FR', 'IT');
+ expect(regionLayers().length).toBe(1);
+
+ // Losing France from the view is a change, so that one does redraw.
+ await panTo('IT');
+ expect(regionLayers().length).toBe(2);
+ expect(regionCodesOf(newestRegionLayer())).toEqual(['IT-62']);
+ });
+
+ it('FE-HOOK-ATLAS-041: the cache stops at a dozen countries and reloads what fell out (#1950)', async () => {
+ // Fourteen countries seen one at a time: the oldest two are gone by the end, so
+ // coming back to the first one has to ask the server again.
+ const seen = ['FR', 'IT', 'ES', 'PT', 'NL', 'BE', 'AT', 'CH', 'PL', 'CZ', 'SE', 'NO', 'DK', 'FI'];
+ const geo = {
+ type: 'FeatureCollection',
+ // Deliberately the other way round from the visiting order: with both the same,
+ // dropping the country from the front of the dataset and dropping the one seen
+ // longest ago pick the same victim, and nothing below tells them apart.
+ features: [...seen].reverse().map((a2) => feature({ ISO_A2: a2, ADM0_A3: A2_TO_A3[a2], ISO_A3: A2_TO_A3[a2], NAME: a2, ADMIN: a2 })),
+ };
+ const regionGeo = {
+ type: 'FeatureCollection',
+ features: seen.map((a2) => feature({ iso_a2: a2.toLowerCase(), iso_3166_2: `${a2}-01`, name: `${a2} region`, admin: a2 })),
+ };
+ await mountAtlas({ geo, regionGeo });
+ await waitFor(() => expect(atlas.atlas_country_options.length).toBe(seen.length));
+
+ const requested: string[] = [];
+ server.use(http.get('/api/addons/atlas/regions/geo', ({ request }) => {
+ requested.push(new URL(request.url).searchParams.get('countries') ?? '');
+ return HttpResponse.json(regionGeo);
+ }));
+
+ lf.zoom = 6;
+ for (let i = 0; i < seen.length; i++) {
+ await panTo(seen[i]);
+ await waitFor(() => expect(requested).toHaveLength(i + 1));
+ }
+ expect(requested).toEqual(seen);
+
+ await panTo('FR');
+ await waitFor(() => expect(requested).toHaveLength(seen.length + 1));
+ expect(requested[requested.length - 1]).toBe('FR');
+
+ // The country the view sat on longest is still cached, so it costs nothing.
+ await panTo('FI');
+ expect(regionCodesOf(newestRegionLayer())).toEqual(['FI-01']);
+ expect(requested).toHaveLength(seen.length + 1);
+
+ // And the two the view left just before that are still there as well. They sit at
+ // the front of the dataset, which is the whole difference between dropping by
+ // position and dropping by how long ago the country was last on screen.
+ await panTo('DK');
+ await panTo('NO');
+ expect(requested).toHaveLength(seen.length + 1);
+ });
+
+ it('FE-HOOK-ATLAS-042: a theme change and the planned toggle still restyle the regions (#1950)', async () => {
+ await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
+ await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
+
+ await zoomTo('FR');
+ await waitFor(() => expect(regionLayers().length).toBe(1));
+
+ const unvisitedFill = (layer: MockGeoJson) =>
+ (layer.options.style as (f: unknown) => { fillColor: string })(twoRegions.features[0]).fillColor;
+ expect(unvisitedFill(newestRegionLayer()!)).toBe('#000000');
+
+ act(() => atlas.togglePlanned());
+ await waitFor(() => expect(regionLayers().length).toBe(2));
+
+ // Dark mode rebuilds the map itself, so the layer has to come back dark-styled
+ // even though the countries in view never changed.
+ act(() => { seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: true }) }); });
+ await waitFor(() => expect(regionLayers().length).toBe(3));
+ expect(unvisitedFill(newestRegionLayer()!)).toBe('#ffffff');
+ });
+
+ it('FE-HOOK-ATLAS-043: flying to a country from the search draws its regions (#1950)', async () => {
+ await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
+ await waitFor(() => expect(atlas.atlas_country_options.length).toBe(3));
+
+ // fitBounds puts the view on Italy alone; the zoomend that follows must not cull it.
+ await act(async () => { atlas.select_country_from_search('IT'); });
+ await zoomTo('IT');
+
+ await waitFor(() => expect(regionCodesOf(newestRegionLayer())).toEqual(['IT-62']));
+ });
+
+ it('FE-HOOK-ATLAS-044: zooming out to 5 keeps the drawn regions on the map (#1950)', async () => {
+ await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
+ await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
+
+ // Both countries land in the cache, then the view narrows to France alone.
+ await zoomTo('FR', 'IT');
+ await waitFor(() => expect(regionLayers().length).toBe(1));
+ await panTo('FR');
+ await waitFor(() => expect(regionCodesOf(newestRegionLayer())).toEqual(['FR-BRE']));
+ const shown = newestRegionLayer()!;
+ expect(shown.attached).toBe(true);
+
+ // One step out. Regions still show at zoom 5 and the country layer stops taking
+ // clicks there, so the region layer is the only way to mark anything. The wider view
+ // pulls Italy back in, which is exactly the change that asks for a rebuild, and the
+ // map only accepts a rebuilt layer from zoom 6, so it must not happen here.
+ lf.zoom = 5;
+ lf.intersectsOnly = ['FR', 'IT'];
+ await act(async () => { lf.mapHandlers.zoomend?.forEach((cb) => cb()); });
+ await act(async () => { lf.mapHandlers.moveend?.forEach((cb) => cb()); });
+
+ expect(lf.panes.overlayPane.style.pointerEvents).toBe('none');
+ expect(shown.attached).toBe(true);
+ expect(newestRegionLayer()).toBe(shown);
+ });
});
describe('planned countries (#1048)', () => {
diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts
index 140f386a3..d67ac283b 100644
--- a/client/src/pages/atlas/useAtlas.ts
+++ b/client/src/pages/atlas/useAtlas.ts
@@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
-import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
+import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, regionCacheEvictions, withCountryMarkedVisited, wishlistA3Codes, countryColor, REGION_CACHE_MAX, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
import { continentForCountry, escapeHtml, type VisitStatus } from '@trek/shared'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
@@ -82,6 +82,12 @@ export function useAtlas() {
const mapRef = useRef(null)
const mapInstance = useRef(null)
const geoLayerRef = useRef(null)
+ // One renderer per map, not one per redraw: Leaflet registers a renderer as a layer
+ // of its own and leaves its container in the pane when the GeoJSON layer is removed,
+ // so building a fresh one on every rebuild left an orphaned canvas/svg behind that
+ // kept redrawing itself on every pan for the rest of the session (#1950).
+ const countryRendererRef = useRef(null)
+ const regionRendererRef = useRef(null)
const glareRef = useRef(null)
const borderGlareRef = useRef(null)
const panelRef = useRef(null)
@@ -117,6 +123,16 @@ export function useAtlas() {
const pluginLayerRef = useRef(null)
const regionLayerRef = useRef(null)
const regionGeoCache = useRef>({})
+ // Cached countries, least recently in view first, capped at REGION_CACHE_MAX.
+ const regionCacheOrder = useRef([])
+ // Countries whose /regions/geo answer is still on the way. They must not be asked
+ // for twice while a pan fires moveend over and over, and must not be evicted before
+ // their own response lands.
+ const pendingRegionCodes = useRef>(new Set())
+ // Which countries the drawn layer was built from, so panning inside the same set of
+ // countries costs nothing.
+ const renderedRegionSigRef = useRef('')
+ const rebuildRegionLayerRef = useRef<(force?: boolean) => void>(() => {})
const [showRegions, setShowRegions] = useState(false)
// Countries you only plan to visit stay off the map until asked for — the atlas is a
// record of where you have been, not of where you booked a flight to (#1048).
@@ -242,6 +258,52 @@ export function useAtlas() {
.catch(() => setPluginLayers([]))
}, [])
+ /** The view the map currently shows, or null while there is no map to ask. */
+ const viewportBounds = (): L.LatLngBounds | null => {
+ try {
+ return mapInstance.current?.getBounds() ?? null
+ } catch {
+ return null
+ }
+ }
+
+ /** Does this country's outline touch the given view? Fail open: a country we hold no
+ * outline for, or one whose bounds throw, counts as in view, because dropping it
+ * would blank regions the user can see. */
+ const countryInView = (code: string, bounds: L.LatLngBounds | null): boolean => {
+ if (!bounds) return true
+ const layer = country_layer_by_a2_ref.current[code]
+ if (!layer) return true
+ try {
+ return bounds.intersects(layer.getBounds())
+ } catch {
+ return true
+ }
+ }
+
+ const touchRegionCode = (code: string): void => {
+ const order = regionCacheOrder.current
+ const at = order.indexOf(code)
+ if (at !== -1) order.splice(at, 1)
+ order.push(code)
+ }
+
+ /** Drop the countries nobody is looking at once the cache outgrows its cap. A country
+ * still in view, or one still waiting for its response, is never dropped, or panning
+ * across a wide country would fetch it, evict it and fetch it again. */
+ const evictRegionCache = (): void => {
+ const bounds = viewportBounds()
+ const keep = new Set(pendingRegionCodes.current)
+ for (const code of regionCacheOrder.current) {
+ if (!keep.has(code) && countryInView(code, bounds)) keep.add(code)
+ }
+ for (const code of regionCacheEvictions(regionCacheOrder.current, keep, REGION_CACHE_MAX)) {
+ delete regionGeoCache.current[code]
+ const at = regionCacheOrder.current.indexOf(code)
+ if (at !== -1) regionCacheOrder.current.splice(at, 1)
+ }
+ }
+
// Load admin-1 GeoJSON for countries visible in the current viewport
const loadRegionsForViewportRef = useRef<() => void>(() => {})
const loadRegionsForViewport = (): void => {
@@ -249,12 +311,21 @@ export function useAtlas() {
const bounds = mapInstance.current.getBounds()
const toLoad: string[] = []
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
- if (regionGeoCache.current[code]) continue
+ if (regionGeoCache.current[code]) {
+ // Recency means recency of being on screen. Touching every cached country wrote
+ // the order back into GeoJSON feature order on every moveend, so the eviction
+ // picked its victim by position in the dataset instead of by what the view had
+ // just left, and panning back to that country paid for a refetch.
+ if (countryInView(code, bounds)) touchRegionCode(code)
+ continue
+ }
+ if (pendingRegionCodes.current.has(code)) continue
try {
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
} catch {}
}
if (!toLoad.length) return
+ for (const code of toLoad) pendingRegionCodes.current.add(code)
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
.then(geoRes => {
const geo = geoRes.data
@@ -262,11 +333,12 @@ export function useAtlas() {
let added = false
for (const c of toLoad) {
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
- if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
+ if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; touchRegionCode(c); added = true }
}
- if (added) setRegionGeoLoaded(v => v + 1)
+ if (added) { evictRegionCache(); setRegionGeoLoaded(v => v + 1) }
})
.catch(() => {})
+ .finally(() => { for (const code of toLoad) pendingRegionCodes.current.delete(code) })
}
loadRegionsForViewportRef.current = loadRegionsForViewport
@@ -320,6 +392,10 @@ export function useAtlas() {
map.getPane('regionPane')!.style.zIndex = '401'
mapInstance.current = map
+ // Both renderers live as long as the map does (see the note on the refs); map.remove()
+ // disposes them along with every other layer it holds.
+ countryRendererRef.current = L.canvas({ padding: 0.5, tolerance: 5 })
+ regionRendererRef.current = L.svg({ pane: 'regionPane' })
// Zoom-based region switching
map.on('zoomend', () => {
@@ -337,6 +413,7 @@ export function useAtlas() {
regionLayerRef.current.addTo(map)
}
loadRegionsForViewportRef.current()
+ rebuildRegionLayerRef.current()
} else {
// Physically remove region layer so its SVG paths can't intercept events
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
@@ -348,15 +425,29 @@ export function useAtlas() {
})
map.on('moveend', () => {
- if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
+ if (map.getZoom() < 6) return
+ loadRegionsForViewportRef.current()
+ // A pan that stays over the same countries changes nothing, and the rebuild
+ // notices that for itself, cheaper than working it out here.
+ rebuildRegionLayerRef.current()
})
- return () => { map.remove(); mapInstance.current = null }
+ return () => {
+ map.remove()
+ mapInstance.current = null
+ countryRendererRef.current = null
+ regionRendererRef.current = null
+ // The layer belongs to the map that just went away. Without this the next map's
+ // zoomend would re-attach it, and Leaflet would revive the old renderer by
+ // appending a second container to the pane.
+ regionLayerRef.current = null
+ renderedRegionSigRef.current = ''
+ }
}, [dark, loading])
// Render GeoJSON countries
useEffect(() => {
- if (!mapInstance.current || !geoData || !data) return
+ if (!mapInstance.current || !geoData || !data || !countryRendererRef.current) return
const visitedA3 = new Set(visibleCountries.map(c => A2_TO_A3[c.code]).filter(Boolean))
const plannedA3 = new Set(visibleCountries.filter(c => countryStatus(c) !== 'visited').map(c => A2_TO_A3[c.code]).filter(Boolean))
@@ -378,7 +469,7 @@ export function useAtlas() {
const colorForCode = countryColor
const wishlistPatternCache = new Map()
- const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
+ const canvasRenderer = countryRendererRef.current
geoLayerRef.current = L.geoJSON(geoData, {
renderer: canvasRenderer,
@@ -541,17 +632,35 @@ export function useAtlas() {
// layers fetched before that would otherwise never get drawn.
}, [geoData, pluginLayers, dark, loading])
- // Render sub-national region layer (zoom >= 5)
- useEffect(() => {
- if (!mapInstance.current) return
+ // Render sub-national region layer (zoom >= 5). `force` is for the changes that alter
+ // how the regions look (theme, visits, the planned toggle); the map's own zoom/pan
+ // handlers pass nothing and get a rebuild only when the countries in view changed.
+ const rebuildRegionLayer = (force = false): void => {
+ if (!mapInstance.current || !regionRendererRef.current) return
+ // Below zoom 6 a rebuild can only do harm: it drops the layer that is on the map and
+ // the add at the end of this function starts at 6, so the regions would go and stay
+ // gone. At zoom 5 they are the only clickable layer left (the country layer is dimmed
+ // and pointer-events off), so the layer built further in has to survive the zoom out.
+ if (!force && mapInstance.current.getZoom() < 6) return
+ const regionRenderer = regionRendererRef.current
+
+ // Draw only the countries that are actually on screen. The cache deliberately
+ // outlives the viewport so panning back is free, but merging all of it into one
+ // layer meant a continent's worth of admin-1 polygons stayed in the DOM, and every
+ // newly loaded country tore the whole thing down and built it again (#1950).
+ const bounds = viewportBounds()
+ const inViewCodes = Object.keys(regionGeoCache.current).filter(code => countryInView(code, bounds)).sort()
+ const sig = inViewCodes.join('|')
+ if (!force && sig === renderedRegionSigRef.current) return
// Remove existing region layer
if (regionLayerRef.current) {
mapInstance.current.removeLayer(regionLayerRef.current)
regionLayerRef.current = null
}
+ renderedRegionSigRef.current = sig
- if (Object.keys(regionGeoCache.current).length === 0) return
+ if (inViewCodes.length === 0) return
// Build set of visited region codes and per-country name sets. Regions follow their
// country's status, so zooming into a planned country can't reveal "visited" regions.
@@ -598,10 +707,11 @@ export function useAtlas() {
const isVisitedFeature = (f: any) => matchesRegions(f, visitedRegionCodes, visitedRegionNamesByCountry)
const isPlannedFeature = (f: any) => matchesRegions(f, plannedRegionCodes, plannedRegionNamesByCountry)
- // Include ALL region features — visited ones get colored fill, unvisited get outline only
+ // Include every region feature of the countries in view: visited ones get colored
+ // fill, unvisited get outline only (clicking one is how a region gets marked)
const allFeatures: any[] = []
- for (const geo of Object.values(regionGeoCache.current)) {
- for (const f of geo.features) {
+ for (const code of inViewCodes) {
+ for (const f of regionGeoCache.current[code].features) {
allFeatures.push(f)
}
}
@@ -614,10 +724,8 @@ export function useAtlas() {
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
- const svgRenderer = L.svg({ pane: 'regionPane' })
-
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
- renderer: svgRenderer,
+ renderer: regionRenderer,
interactive: true,
pane: 'regionPane',
style: (feature) => {
@@ -706,8 +814,17 @@ export function useAtlas() {
if (mapInstance.current.getZoom() >= 6) {
regionLayerRef.current.addTo(mapInstance.current)
}
+ }
+ // Reassigned every render so the map handlers always call a closure that sees the
+ // current visits, theme and toggle state, same pattern as loadRegionsForViewport.
+ rebuildRegionLayerRef.current = rebuildRegionLayer
+
+ useEffect(() => {
+ // Anything in the deps changes how the regions look rather than which are on screen,
+ // so it has to redraw even when the countries in view are the same ones.
// visitedCountries belongs here: the region colours are derived from it, and without
// the dep this effect kept painting regions from a stale country list.
+ rebuildRegionLayerRef.current(true)
}, [regionGeoLoaded, visitedRegions, dark, t, visitedCountries, showPlanned])
const handleMarkCountry = (code: string, name: string): void => {
diff --git a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts
index 9e8bcc9bb..5b3eecad9 100644
--- a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts
+++ b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { distanceBetweenGeoPoints, sortProviderPhotos } from './JourneyDetailPage.helpers';
+import { distanceBetweenGeoPoints, groupPhotosByDate, sortProviderPhotos } from './JourneyDetailPage.helpers';
describe('Journey provider photo ranking', () => {
it('places GPS photos nearest the selected Journey location and keeps photos without GPS', () => {
@@ -25,3 +25,31 @@ describe('Journey provider photo ranking', () => {
expect(distanceBetweenGeoPoints({ lat: 41.9, lng: 12.5 }, { lat: 41.9, lng: 12.5 })).toBe(0);
});
});
+
+describe('Journey provider photo grouping', () => {
+ it('orders the date headings newest first, whatever order the photos arrive in', () => {
+ const photos = [
+ { id: 'b', takenAt: '2026-03-14T09:00:00Z' },
+ { id: 'a', takenAt: '2026-03-16T09:00:00Z' },
+ { id: 'c', takenAt: '2026-03-15T09:00:00Z' },
+ ];
+ expect(groupPhotosByDate(photos).map((g) => g.date)).toEqual(['2026-03-16', '2026-03-15', '2026-03-14']);
+ });
+
+ it('keeps the distance sort inside a day but not across days', () => {
+ // What sortProviderPhotos hands over: nearest first, so the 14th leads the list.
+ const sorted = [
+ { id: 'near', takenAt: '2026-03-14T10:00:00Z' },
+ { id: 'mid', takenAt: '2026-03-16T10:00:00Z' },
+ { id: 'far', takenAt: '2026-03-14T11:00:00Z' },
+ ];
+ const groups = groupPhotosByDate(sorted);
+ expect(groups.map((g) => g.date)).toEqual(['2026-03-16', '2026-03-14']);
+ expect(groups[1].assets.map((a: { id: string }) => a.id)).toEqual(['near', 'far']);
+ });
+
+ it('sorts photos without a date to the end', () => {
+ const photos = [{ id: 'x' }, { id: 'y', takenAt: '2026-03-16T09:00:00Z' }];
+ expect(groupPhotosByDate(photos).map((g) => g.date)).toEqual(['2026-03-16', '__unknown__']);
+ });
+});
diff --git a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts
index d65021488..a58e6463d 100644
--- a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts
+++ b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts
@@ -66,7 +66,17 @@ export function groupPhotosByDate(photos: any[]): { date: string; label: string;
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(asset)
}
- return [...map.entries()].map(([date, assets]) => ({
+ // Group order must not depend on the order of `photos`. Once the caller sorts by
+ // distance to the entry, first-seen order would put the day holding the nearest
+ // photo first and the date headings would stop reading chronologically. Order
+ // within a day is left as handed in, so the distance sort still applies there.
+ return [...map.entries()]
+ .sort((a, b) => {
+ if (a[0] === '__unknown__') return 1
+ if (b[0] === '__unknown__') return -1
+ return b[0].localeCompare(a[0])
+ })
+ .map(([date, assets]) => ({
date,
label: date === '__unknown__'
? 'Unknown date'
diff --git a/client/src/pages/journeyPublic/journeyPublicModel.ts b/client/src/pages/journeyPublic/journeyPublicModel.ts
index 5ee6a3a76..7cecf9698 100644
--- a/client/src/pages/journeyPublic/journeyPublicModel.ts
+++ b/client/src/pages/journeyPublic/journeyPublicModel.ts
@@ -39,6 +39,11 @@ export interface PublicGalleryPhoto {
owner_id?: number | null
file_path?: string | null
caption?: string | null
+ /** Where and when the picture was taken (#1614). Absent for most photos, and the
+ * coordinates are withheld entirely unless the owner shared the map. */
+ taken_at?: string | null
+ lat?: number | null
+ lng?: number | null
}
export function groupByDate(entries: PublicEntry[]): Map {
diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts
index 71f71ee31..d4ae76fe8 100644
--- a/client/src/pages/journeyPublic/useJourneyPublic.ts
+++ b/client/src/pages/journeyPublic/useJourneyPublic.ts
@@ -53,6 +53,8 @@ export function useJourneyPublic() {
const timelineEntries = useMemo(() => entries, [entries])
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
+ // Chronological throughout: this is what the day colours and the stop numbers are
+ // derived from, so flipping the reading order must not renumber the trip.
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
@@ -63,12 +65,16 @@ export function useJourneyPublic() {
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
// stay in sync with the timeline day headers even when some days have no locations.
+ // The marker number used to be the stop's position *within* its day, while the
+ // timeline heading showed the day number — two different numbers in the same
+ // colour, and on mobile the heading is not rendered at all, so the key was
+ // missing entirely (#1962). It is now the stop's place in the whole journey, so
+ // every number appears exactly once and the colour still groups the day.
const sidebarMapItems = useMemo(() => {
- const counters = new Map()
+ let stop = 0
return mapEntries.map(e => {
const dayIdx = sortedDates.indexOf(e.entry_date)
- const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
- counters.set(e.entry_date, dayLabel)
+ const dayLabel = ++stop
return {
id: String(e.id),
lat: e.location_lat!,
@@ -83,6 +89,33 @@ export function useJourneyPublic() {
})
}, [mapEntries, sortedDates])
+ // The same number the marker carries, so the timeline is the key to the map
+ // rather than a second, unrelated numbering.
+ const stopNumberById = useMemo(() => {
+ const m = new Map()
+ sidebarMapItems.forEach(i => m.set(i.id, i.dayLabel))
+ return m
+ }, [sidebarMapItems])
+
+ // Photos that know where they were taken. Only meaningful when the map is shared —
+ // the server nulls the coordinates otherwise, so this comes out empty by itself.
+ const mapPhotos = useMemo(
+ () => gallery
+ .filter(p => typeof p.lat === 'number' && typeof p.lng === 'number')
+ .map(p => ({ id: String(p.id), lat: p.lat!, lng: p.lng!, photoId: p.photo_id })),
+ [gallery],
+ )
+
+ // A journey shared while the trip is still running reads like a blog, so the owner
+ // can publish it newest-first (#1614). The reader may flip it either way; only the
+ // display order changes, never the numbering.
+ const [newestFirst, setNewestFirst] = useState(null)
+ const effectiveNewestFirst = newestFirst ?? !!perms.newest_first
+ const displayDates = useMemo(
+ () => (effectiveNewestFirst ? [...sortedDates].reverse() : sortedDates),
+ [sortedDates, effectiveNewestFirst],
+ )
+
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
@@ -102,7 +135,8 @@ export function useJourneyPublic() {
view, setView, lightbox, setLightbox, showLangPicker, setShowLangPicker,
mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
perms, journey, stats,
- timelineEntries, groupedEntries, sortedDates, sidebarMapItems, allPhotos,
+ timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
+ newestFirst: effectiveNewestFirst, setNewestFirst,
desktopTwoColumn,
}
}
diff --git a/client/src/utils/dayMerge.test.ts b/client/src/utils/dayMerge.test.ts
index 8f10f884c..b16dcd022 100644
--- a/client/src/utils/dayMerge.test.ts
+++ b/client/src/utils/dayMerge.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
+import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
describe('parseTimeToMinutes', () => {
it('parses HH:MM string', () => {
@@ -34,6 +34,49 @@ describe('getSpanPhase', () => {
})
})
+describe('hidesOnMiddleDay', () => {
+ it('keeps a one-day parking on its only day', () => {
+ expect(hidesOnMiddleDay({ type: 'parking', day_id: 1, end_day_id: 1 }, 1)).toBe(false)
+ })
+
+ it('keeps a two-day parking on both days (no day in between exists)', () => {
+ const parking = { type: 'parking', day_id: 1, end_day_id: 2 }
+ expect(hidesOnMiddleDay(parking, 1)).toBe(false)
+ expect(hidesOnMiddleDay(parking, 2)).toBe(false)
+ })
+
+ it('hides a three-day parking only on the day in between', () => {
+ const parking = { type: 'parking', day_id: 1, end_day_id: 3 }
+ expect(hidesOnMiddleDay(parking, 1)).toBe(false)
+ expect(hidesOnMiddleDay(parking, 2)).toBe(true)
+ expect(hidesOnMiddleDay(parking, 3)).toBe(false)
+ })
+
+ it('hides every day in between of a longer parking span (#1937)', () => {
+ const parking = { type: 'parking', day_id: 1, end_day_id: 5 }
+ expect([1, 2, 3, 4, 5].map(d => hidesOnMiddleDay(parking, d)))
+ .toEqual([false, true, true, true, false])
+ })
+
+ it('leaves a car rental visible, since its middle days move to the day header', () => {
+ expect(hidesOnMiddleDay({ type: 'car', day_id: 1, end_day_id: 3 }, 2)).toBe(false)
+ })
+
+ it('leaves every other booking type alone', () => {
+ for (const type of ['train', 'cruise', 'event', 'hotel', 'other']) {
+ expect(hidesOnMiddleDay({ type, day_id: 1, end_day_id: 3 }, 2)).toBe(false)
+ }
+ })
+
+ it('keeps a parking whose end day is not part of the trip', () => {
+ expect(hidesOnMiddleDay({ type: 'parking', day_id: 1, end_day_id: 999 }, 1)).toBe(false)
+ })
+
+ it('keeps an unscheduled parking', () => {
+ expect(hidesOnMiddleDay({ type: 'parking', day_id: null, end_day_id: null }, 2)).toBe(false)
+ })
+})
+
describe('getTransportRouteEndpoints', () => {
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts
index c117693b5..871668a2c 100644
--- a/client/src/utils/dayMerge.ts
+++ b/client/src/utils/dayMerge.ts
@@ -29,6 +29,26 @@ export function getSpanPhase(
return 'middle'
}
+/**
+ * Booking types that carry no information on the days between their start and end and
+ * therefore drop out of the day timeline there. A parked car just stands in the garage:
+ * you only need to know when you hand it over and when you collect it, so the days in
+ * between show nothing at all, not even a badge in the day header (#1937).
+ *
+ * A car rental is deliberately NOT in this set: its middle days are not dropped but
+ * moved into the day header, which is what people expect from a vehicle they are
+ * driving around. Kept as a set so another such type can join without editing every
+ * render site again.
+ */
+const MIDDLE_DAY_HIDDEN_TYPES = new Set(['parking'])
+
+export function hidesOnMiddleDay(
+ r: { type?: string | null; day_id?: number | null; end_day_id?: number | null },
+ dayId: number
+): boolean {
+ return !!r.type && MIDDLE_DAY_HIDDEN_TYPES.has(r.type) && getSpanPhase(r, dayId) === 'middle'
+}
+
/**
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
* A car rental (or any reservation whose span covers several days) is only routed to on its
diff --git a/client/src/utils/flightLegs.test.ts b/client/src/utils/flightLegs.test.ts
index 9ae8b5af5..33d6e7bd8 100644
--- a/client/src/utils/flightLegs.test.ts
+++ b/client/src/utils/flightLegs.test.ts
@@ -50,3 +50,49 @@ describe('getTrainLegs (#1150)', () => {
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH 400' })
})
})
+
+describe('per-segment booking references (#1943)', () => {
+ it('reads each segment its own code, and leaves a code-less segment undefined', () => {
+ const flight = res({
+ type: 'flight',
+ confirmation_number: 'BOOK1',
+ metadata: JSON.stringify({
+ legs: [
+ { from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
+ { from: 'BER', to: 'HND' },
+ ],
+ }),
+ })
+ const legs = getFlightLegs(flight)
+ expect(legs[0].confirmation_number).toBe('ABC123')
+ // No fallback to the booking's own code: a segment either has one or it does
+ // not, and the booking's reference is shown separately.
+ expect(legs[1].confirmation_number).toBeUndefined()
+ })
+
+ it('reads a train segment code the same way', () => {
+ const train = res({
+ metadata: JSON.stringify({
+ legs: [
+ { from: 'Berlin Hbf', to: 'Frankfurt Hbf', confirmation_number: 'DB-1' },
+ { from: 'Frankfurt Hbf', to: 'München Hbf', confirmation_number: 'DB-2' },
+ ],
+ }),
+ })
+ expect(getTrainLegs(train).map(l => l.confirmation_number)).toEqual(['DB-1', 'DB-2'])
+ })
+
+ it('gives the derived single leg of a legacy booking the booking code', () => {
+ const flight = res({
+ type: 'flight', confirmation_number: 'BOOK1', day_id: 3,
+ metadata: JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK' }),
+ })
+ expect(getFlightLegs(flight)[0].confirmation_number).toBe('BOOK1')
+ const train = res({
+ confirmation_number: 'BOOK2',
+ endpoints: [ep('from', 0, 'Köln Hbf'), ep('to', 1, 'Aachen Hbf')],
+ metadata: '{}',
+ })
+ expect(getTrainLegs(train)[0].confirmation_number).toBe('BOOK2')
+ })
+})
diff --git a/client/src/utils/flightLegs.ts b/client/src/utils/flightLegs.ts
index d5ba77ff3..fa7179db0 100644
--- a/client/src/utils/flightLegs.ts
+++ b/client/src/utils/flightLegs.ts
@@ -19,6 +19,8 @@ export interface FlightLeg {
to: string | null
airline?: string
flight_number?: string
+ /** This segment's own booking reference, when it was booked separately (#1943). */
+ confirmation_number?: string
dep_day_id?: number | null
dep_time?: string | null // 'HH:mm'
arr_day_id?: number | null
@@ -59,6 +61,7 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
to: l.to ?? null,
airline: l.airline || undefined,
flight_number: l.flight_number || undefined,
+ confirmation_number: l.confirmation_number || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
@@ -77,6 +80,8 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
to: toCode,
airline: meta.airline || undefined,
flight_number: meta.flight_number || undefined,
+ // The single segment IS the booking, so its reference is the booking's.
+ confirmation_number: r.confirmation_number || undefined,
dep_day_id: r.day_id ?? null,
dep_time: first?.local_time ?? null,
arr_day_id: r.end_day_id ?? r.day_id ?? null,
@@ -95,6 +100,8 @@ export interface TrainLeg {
train_number?: string
platform?: string
seat?: string
+ /** This segment's own booking reference, when it was booked separately (#1943). */
+ confirmation_number?: string
dep_day_id?: number | null
dep_time?: string | null
arr_day_id?: number | null
@@ -115,6 +122,7 @@ export function getTrainLegs(r: Reservation): TrainLeg[] {
train_number: l.train_number || undefined,
platform: l.platform || undefined,
seat: l.seat || undefined,
+ confirmation_number: l.confirmation_number || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
@@ -133,6 +141,8 @@ export function getTrainLegs(r: Reservation): TrainLeg[] {
train_number: meta.train_number || undefined,
platform: meta.platform || undefined,
seat: meta.seat || undefined,
+ // The single segment IS the booking, so its reference is the booking's.
+ confirmation_number: r.confirmation_number || undefined,
dep_day_id: r.day_id ?? null,
dep_time: first?.local_time ?? null,
arr_day_id: r.end_day_id ?? r.day_id ?? null,
diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
index 6386279ed..ccdbb0a6e 100644
--- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
+++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
@@ -48,6 +48,24 @@ describe('MJourneyEntrySheet quick capture', () => {
});
});
+ // #1614 — the ask was "photos *or* quick notes" while travelling, and a moment
+ // without a picture is still worth catching.
+ it('takes a note in capture mode and saves it', async () => {
+ vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Singapore', address: 'Singapore' });
+ const { props } = setup();
+
+ const note = await screen.findByPlaceholderText('Write your story...');
+ fireEvent.change(note, { target: { value: 'Ferry was late, worth it' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ await waitFor(() =>
+ expect(props.onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ story: 'Ferry was late, worth it' }),
+ undefined,
+ ),
+ );
+ });
+
it('captures GPS, reverse-geocodes it, detects an editable weather category, and saves', async () => {
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Singapore', address: 'Singapore' });
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx
index be5ecf4a5..dec2b9b68 100644
--- a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx
@@ -8,7 +8,7 @@ import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen } from '../../../helpers/render'
-// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-046
+// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-048
//
// The sheet's own pickers (airport/location search, day select, time picker) and
// the embedded transit panel are replaced by minimal controlled stand-ins so the
@@ -632,6 +632,62 @@ describe('MTransportFormSheet', () => {
expect(legs[1]).not.toHaveProperty('day_positions')
})
+ it('FE-MOB-TRFRM-047: each segment of a layover saves its own booking code (#1943)', async () => {
+ const handleSaveTransport = makeSave()
+ renderSheet(makePlanner({ handleSaveTransport }))
+ typeTitle('FRA-IST-HND')
+ // A direct flight writes no legs, so only the booking's own field is offered.
+ expect(screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')).toHaveLength(1)
+
+ fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
+ setValue(airportInputs()[0], 'FRA')
+ setValue(airportInputs()[1], 'IST')
+ setValue(airportInputs()[2], 'HND')
+
+ const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')
+ expect(codes).toHaveLength(3)
+ setValue(codes[0], 'ABC123')
+ setValue(codes[1], 'XYZ789')
+ setValue(codes[2], 'BOOK1')
+
+ await submit()
+
+ const payload = handleSaveTransport.mock.calls[0][0]
+ const legs = (payload.metadata as { legs: Record[] }).legs
+ expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
+ // The booking's own reference stays on its column, never on a segment.
+ expect(payload.confirmation_number).toBe('BOOK1')
+ expect(payload.metadata).not.toHaveProperty('confirmation_number')
+ })
+
+ it('FE-MOB-TRFRM-048: a re-save on the phone keeps the stored segment codes (#1943)', async () => {
+ const handleSaveTransport = makeSave()
+ const editingTransport = {
+ id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending',
+ day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1',
+ metadata: {
+ legs: [
+ { from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' },
+ { from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
+ ],
+ },
+ endpoints: [
+ { id: 1, reservation_id: 45, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null },
+ { id: 2, reservation_id: 45, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null },
+ { id: 3, reservation_id: 45, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null },
+ ],
+ } as unknown as Reservation
+ renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
+
+ const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[]
+ expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1'])
+
+ await submit('common.update')
+
+ const legs = (handleSaveTransport.mock.calls[0][0].metadata as { legs: Record[] }).legs
+ expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
+ })
+
it('FE-MOB-TRFRM-023: an edited flight without endpoints falls back to the reservation times', () => {
const editingTransport = {
id: 44, trip_id: 1, type: 'flight', title: 'Legacy flight', status: 'pending',
diff --git a/client/tests/unit/mobile/trip/MTransportSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
index d91d60070..a5488a3a8 100644
--- a/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
-// FE-MOB-TRSH-001 to FE-MOB-TRSH-020
+// FE-MOB-TRSH-001 to FE-MOB-TRSH-024
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
@@ -265,6 +265,36 @@ describe('MTransportSheet', () => {
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
})
+ const layover = (): Reservation => ({
+ ...FLIGHT,
+ metadata: JSON.stringify({
+ airline: 'Lufthansa', flight_number: 'LH 716',
+ legs: [
+ { from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH 716', confirmation_number: 'ABC123' },
+ { from: 'BER', to: 'HND', airline: 'ANA', flight_number: 'NH 204' },
+ ],
+ }),
+ } as unknown as Reservation)
+
+ it('FE-MOB-TRSH-023: lists the booking code of every segment that has its own (#1943)', () => {
+ render()
+ // The booking's own reference keeps its row above.
+ expect(screen.getByText('#QW7788')).toBeInTheDocument()
+ expect(screen.getByText('FRA → BER')).toBeInTheDocument()
+ expect(screen.getByText('#ABC123')).toBeInTheDocument()
+ // A segment without one contributes no row.
+ expect(screen.queryByText('BER → HND')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-TRSH-024: a segment code is blurred until it is tapped', () => {
+ seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, blur_booking_codes: true } })
+ render()
+ const code = screen.getByText('#ABC123')
+ expect(code.className).toContain('blur-[4px]')
+ fireEvent.click(code)
+ expect(screen.getByText('#ABC123').className).not.toContain('blur-[4px]')
+ })
+
it('FE-MOB-TRSH-022: closes from the header button', () => {
const shell = makeShell()
render()
diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx
index 5d731594e..271987acd 100644
--- a/client/tests/unit/mobile/trip/MTripShell.test.tsx
+++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx
@@ -257,6 +257,36 @@ describe('MTripShell', () => {
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
})
+ // #1962 — the phone lost the desktop sidebar's collapse chevron, so the map showed
+ // every day's pins at once with no way to narrow it down.
+ it('FE-MOB-SHELL-040: entering the map focuses it on the selected day', () => {
+ const { planner } = renderShell()
+ fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
+ expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([11]))
+ })
+
+ it('FE-MOB-SHELL-041: a day tap in map mode moves the focus with it', () => {
+ const { planner } = renderShell()
+ fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
+ vi.mocked(planner.setExpandedDayIds).mockClear()
+ fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
+ expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([12]))
+ })
+
+ it('FE-MOB-SHELL-042: a day tap in list mode leaves the map unfiltered', () => {
+ const { planner } = renderShell()
+ fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
+ expect(planner.setExpandedDayIds).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-SHELL-043: leaving the map clears the focus again', () => {
+ const { planner } = renderShell()
+ fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
+ vi.mocked(planner.setExpandedDayIds).mockClear()
+ fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' }))
+ expect(planner.setExpandedDayIds).toHaveBeenCalledWith(null)
+ })
+
it('FE-MOB-SHELL-023: the dock only shows enabled tabs and marks the active one', () => {
const { planner } = renderShell({
TRIP_TABS: [
diff --git a/client/tests/unit/mobile/trip/planTimelineModel.test.ts b/client/tests/unit/mobile/trip/planTimelineModel.test.ts
index 2df86b296..725d0a11c 100644
--- a/client/tests/unit/mobile/trip/planTimelineModel.test.ts
+++ b/client/tests/unit/mobile/trip/planTimelineModel.test.ts
@@ -11,7 +11,7 @@ import type {
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
} from '../../../../src/types'
-// FE-MOB-PTLM-001 to FE-MOB-PTLM-042
+// FE-MOB-PTLM-001 to FE-MOB-PTLM-043
const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
@@ -192,6 +192,13 @@ describe('planTimelineModel — buildPlanRows', () => {
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
})
+ it('FE-MOB-PTLM-043: hides a parking on the days between drop-off and pickup (#1937)', () => {
+ const parking = buildReservation({ id: 63, type: 'parking', day_id: 1, end_day_id: 3 })
+ expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
+ expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
+ expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 3 })).toHaveLength(1)
+ })
+
it('FE-MOB-PTLM-017: slots a connector between two located places and tags its origin', () => {
const leg = seg([48.1, 16.1], [48.2, 16.2])
const rows = buildPlanRows({
diff --git a/docs/logo-trek-dark.svg b/docs/logo-trek-dark.svg
new file mode 100644
index 000000000..56684fba3
--- /dev/null
+++ b/docs/logo-trek-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/logo-trek-light.svg b/docs/logo-trek-light.svg
new file mode 100644
index 000000000..eb276d67e
--- /dev/null
+++ b/docs/logo-trek-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/screenshots/admin.png b/docs/screenshots/admin.png
deleted file mode 100644
index 4e2f4977b..000000000
Binary files a/docs/screenshots/admin.png and /dev/null differ
diff --git a/docs/screenshots/atlas.png b/docs/screenshots/atlas.png
deleted file mode 100644
index 19688de14..000000000
Binary files a/docs/screenshots/atlas.png and /dev/null differ
diff --git a/docs/screenshots/budget.png b/docs/screenshots/budget.png
deleted file mode 100644
index c7017ecb2..000000000
Binary files a/docs/screenshots/budget.png and /dev/null differ
diff --git a/docs/screenshots/collections.png b/docs/screenshots/collections.png
deleted file mode 100644
index e3ea6efea..000000000
Binary files a/docs/screenshots/collections.png and /dev/null differ
diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png
deleted file mode 100644
index f1cc1240a..000000000
Binary files a/docs/screenshots/dashboard.png and /dev/null differ
diff --git a/docs/screenshots/journey.png b/docs/screenshots/journey.png
deleted file mode 100644
index 5c4560d6b..000000000
Binary files a/docs/screenshots/journey.png and /dev/null differ
diff --git a/docs/screenshots/showcase-desktop-1.webp b/docs/screenshots/showcase-desktop-1.webp
new file mode 100644
index 000000000..52cde32d0
Binary files /dev/null and b/docs/screenshots/showcase-desktop-1.webp differ
diff --git a/docs/screenshots/showcase-desktop-2.webp b/docs/screenshots/showcase-desktop-2.webp
new file mode 100644
index 000000000..8cce61d9b
Binary files /dev/null and b/docs/screenshots/showcase-desktop-2.webp differ
diff --git a/docs/screenshots/showcase-mobile.webp b/docs/screenshots/showcase-mobile.webp
new file mode 100644
index 000000000..c3b0ffb34
Binary files /dev/null and b/docs/screenshots/showcase-mobile.webp differ
diff --git a/docs/screenshots/trip-planner.png b/docs/screenshots/trip-planner.png
deleted file mode 100644
index b73586534..000000000
Binary files a/docs/screenshots/trip-planner.png and /dev/null differ
diff --git a/docs/screenshots/vacay.png b/docs/screenshots/vacay.png
deleted file mode 100644
index 02d235f98..000000000
Binary files a/docs/screenshots/vacay.png and /dev/null differ
diff --git a/docs/tiles/grid-desktop-dark.svg b/docs/tiles/grid-desktop-dark.svg
new file mode 100644
index 000000000..08c79af45
--- /dev/null
+++ b/docs/tiles/grid-desktop-dark.svg
@@ -0,0 +1,62 @@
+
diff --git a/docs/tiles/grid-desktop.svg b/docs/tiles/grid-desktop.svg
index 5070a59ae..513c9e864 100644
--- a/docs/tiles/grid-desktop.svg
+++ b/docs/tiles/grid-desktop.svg
@@ -1,146 +1,62 @@
-
diff --git a/docs/tiles/grid-mobile.svg b/docs/tiles/grid-mobile.svg
index e4d74d1bf..54d5cb3c2 100644
--- a/docs/tiles/grid-mobile.svg
+++ b/docs/tiles/grid-mobile.svg
@@ -1,130 +1,62 @@
-
-
-
-
-
-
-
-
-
-
-
- TRIP PLANNER
- Drag and drop
- day by day
- Reorder, move across days, optimise
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- MAPS
- See it all
- on the map
- Leaflet + Mapbox GL, 3D buildings
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- COLLAB
- Plan together
- in real time
- WebSocket sync, chat, polls, notes
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- BUDGET
- Track costs
- per person
- Pie chart, multi-currency, splits
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- PACKING
- Lists, sorted.
- by category
- Templates, bag tracking, weights
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- JOURNEY
- A journal for
- every trip
- Magazine entries, photos, maps
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- VACAY
- Vacation days,
- visualised
- Calendar, 100+ country holidays
-
-
-
-
-
-
-
-
-
-
-
-
- AI / MCP
- Let AI plan
- your trips
- 80+ tools, OAuth 2.1, Claude-ready
-
-
-
\ No newline at end of file
+
+
+
diff --git a/package-lock.json b/package-lock.json
index dcae771a1..01761e4ce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2686,16 +2686,16 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
@@ -2782,9 +2782,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5433,20 +5433,20 @@
}
},
"node_modules/@nestjs/swagger": {
- "version": "11.4.5",
- "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz",
- "integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==",
+ "version": "11.4.7",
+ "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
+ "integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.1",
- "js-yaml": "4.3.0",
+ "js-yaml": "5.3.0",
"lodash": "4.18.1",
"path-to-regexp": "8.4.2",
- "swagger-ui-dist": "5.32.8"
+ "swagger-ui-dist": "5.32.13"
},
"peerDependencies": {
- "@fastify/static": "^8.0.0 || ^9.0.0",
+ "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"class-transformer": "*",
@@ -5465,6 +5465,28 @@
}
}
},
+ "node_modules/@nestjs/swagger/node_modules/js-yaml": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
+ "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.mjs"
+ }
+ },
"node_modules/@nestjs/testing": {
"version": "11.1.27",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.27.tgz",
@@ -7816,16 +7838,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -8931,9 +8953,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
- "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -10722,16 +10744,16 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
@@ -10898,6 +10920,12 @@
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
},
+ "node_modules/exifr": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+ "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
+ "license": "MIT"
+ },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -11105,9 +11133,9 @@
}
},
"node_modules/fast-uri": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
- "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -12218,9 +12246,9 @@
}
},
"node_modules/ip-address": {
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
+ "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -13062,6 +13090,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -15331,16 +15360,16 @@
}
},
"node_modules/nodemon/node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/nodemon/node_modules/has-flag": {
@@ -18624,9 +18653,9 @@
}
},
"node_modules/swagger-ui-dist": {
- "version": "5.32.8",
- "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
- "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
+ "version": "5.32.13",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
+ "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -20731,16 +20760,16 @@
}
},
"node_modules/workbox-build/node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/workbox-build/node_modules/fs-extra": {
@@ -21207,6 +21236,7 @@
"cors": "^2.8.5",
"cron": "^4.4.0",
"dotenv": "^16.4.1",
+ "exifr": "7.1.3",
"express": "^4.18.3",
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
@@ -21367,9 +21397,9 @@
}
},
"server/node_modules/brace-expansion": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/server/package.json b/server/package.json
index 10a5f58d1..e9d81749b 100644
--- a/server/package.json
+++ b/server/package.json
@@ -45,6 +45,7 @@
"cors": "^2.8.5",
"cron": "^4.4.0",
"dotenv": "^16.4.1",
+ "exifr": "7.1.3",
"express": "^4.18.3",
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts
index ae8bfdf03..d3e113614 100644
--- a/server/scripts/migrate-encryption.ts
+++ b/server/scripts/migrate-encryption.ts
@@ -273,8 +273,24 @@ async function main() {
}
db.transaction(() => {
- // --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token ---
- for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
+ // --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token,
+ // plus the instance-wide provider keys (#1939) ---
+ //
+ // The last two mirror INSTANCE_API_KEY_NAMES in
+ // src/nest/settings/instance-api-keys.ts. Spelled out rather than imported,
+ // because that module pulls in DatabaseService and this script deliberately
+ // stays independent of src/ (see the crypto note above). Miss one and a
+ // rotation leaves it encrypted under the old key, which reads back as "no
+ // key" and silently drops the install to OpenStreetMap, so the copy is
+ // pinned by tests/unit/db/migrate-encryption-parity.test.ts.
+ for (const key of [
+ 'oidc_client_secret',
+ 'smtp_pass',
+ 'admin_webhook_url',
+ 'admin_ntfy_token',
+ 'maps_api_key',
+ 'unsplash_api_key',
+ ]) {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row?.value) continue;
const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`);
@@ -284,7 +300,10 @@ async function main() {
}
// --- users: api key columns + synology credentials ---
- const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
+ // unsplash_api_key was missing here for as long as the column has existed. It
+ // matters now that the resolver reads it as the per-user fallback (#1939): left
+ // out of a rotation it stays encrypted under the old key and reads back as unset.
+ const apiKeyColumns = ['maps_api_key', 'unsplash_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
for (const user of users) {
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 0d6b93752..abff46282 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -3909,13 +3909,89 @@ function runMigrations(db: Database.Database): void {
)
`);
},
+ // #1939 — the Google Places and Unsplash keys are instance configuration and
+ // now resolve out of app_settings (nest/settings/instance-api-keys.ts). This
+ // carries the value the old resolver would have handed out — the lowest-id
+ // admin who has one — into that row, so an install that upgrades keeps the
+ // key it was searching with instead of falling back to OpenStreetMap.
+ // Only where that key already was the whole install's, though. The resolver
+ // reads the instance row before the caller's own column, so promoting a
+ // personal key while a second row still holds one would move that member
+ // onto a stranger's key and a stranger's bill without anyone saying so. As
+ // soon as another row has a value for the same column nothing is written:
+ // everybody keeps resolving what they resolved before, and a member with no
+ // key of their own searches via OpenStreetMap until an admin saves the
+ // instance key once in the panel. Handing a key to the whole instance is the
+ // admin's call, not a migration's.
+ // The users columns are left alone: they are still the per-user fallback,
+ // and nothing here can lose a value. Stored blobs are copied verbatim; both
+ // sides use the same apiKeyCrypto format, legacy plaintext included.
+ // Appended LAST — the array is index-addressed against schema_version.
+ () => {
+ const upsert = db.prepare(
+ `INSERT INTO app_settings (key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
+ );
+ for (const column of ['maps_api_key', 'unsplash_api_key'] as const) {
+ const existing = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(column) as
+ | { value: string | null }
+ | undefined;
+ if (existing?.value) continue;
+ const row = db
+ .prepare(
+ `SELECT id, ${column} AS value FROM users
+ WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != ''
+ ORDER BY id ASC LIMIT 1`
+ )
+ .get() as { id: number; value: string } | undefined;
+ if (!row?.value) continue;
+ const otherHolder = db
+ .prepare(
+ `SELECT 1 FROM users
+ WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1`
+ )
+ .get(row.id);
+ if (otherHolder) continue;
+ upsert.run(column, row.value);
+ }
+ },
+ // Capture metadata on the photo itself (#1614): when it was taken and where.
+ // Both are needed before photos can sit on the Journey map by their own
+ // coordinates, and before a gallery can be ordered by when a picture was
+ // taken rather than when it happened to be added. Nullable throughout —
+ // most providers answer with neither, and a photo without them is normal.
+ // Guarded so re-running the migration tail is a no-op.
+ () => {
+ const cols = db.prepare("SELECT name FROM pragma_table_info('trek_photos')").all() as Array<{ name: string }>;
+ const has = (name: string) => cols.some(c => c.name === name);
+ if (!has('taken_at')) db.exec('ALTER TABLE trek_photos ADD COLUMN taken_at TEXT');
+ if (!has('lat')) db.exec('ALTER TABLE trek_photos ADD COLUMN lat REAL');
+ if (!has('lng')) db.exec('ALTER TABLE trek_photos ADD COLUMN lng REAL');
+ // Answering "which photos of this journey have coordinates" without a scan.
+ db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL');
+ },
+ // A journey shared while the trip is still running reads like a blog, and a
+ // blog puts the newest entry first (#1614). The owner decides per share link,
+ // because it is a property of how the link is meant to be read, not of the
+ // journey. Off by default: an already-published link must not reorder itself
+ // under its readers.
+ () => {
+ const cols = db.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as Array<{ name: string }>;
+ if (!cols.some(c => c.name === 'newest_first')) {
+ db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0');
+ }
+ },
// Storage slice 2 — collab note attachments historically stored 'files/'
// in trip_files.filename while the file manager stored bare names in the same
// column; the storage layer addresses objects as category + bare name, so
// normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of
// 'files/'. The LIKE guard makes it a no-op on already-bare rows.
- // Appended LAST: the array is index-addressed against schema_version, so a
- // slot inserted above this line never runs on an existing database.
+ // Appended LAST (again): this slot moved below the three dev slots above in
+ // the dev merge — the array is index-addressed against schema_version, and
+ // the dev slots keep the indices they shipped with. A database that already
+ // ran this migration at its pre-merge index replays it harmlessly (the LIKE
+ // guard) but SKIPS the #1939 key promotion — acceptable only because this
+ // branch was never published; do not repeat this with a released slot.
() => {
db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'");
},
diff --git a/server/src/demo/demo-reset.ts b/server/src/demo/demo-reset.ts
index 45b2812e4..4eec2c538 100644
--- a/server/src/demo/demo-reset.ts
+++ b/server/src/demo/demo-reset.ts
@@ -27,6 +27,19 @@ function resetDemoUser(): void {
console.error('[Demo Reset] Failed to read admin data:', e instanceof Error ? e.message : e);
}
+ // The Places/Unsplash keys the searches actually use live in app_settings
+ // since #1939, so they have to survive the restore alongside the columns —
+ // otherwise the demo instance loses map search on every reset.
+ interface InstanceKeyRow { key: string; value: string | null }
+ let instanceKeys: InstanceKeyRow[] = [];
+ try {
+ instanceKeys = db.prepare(
+ "SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key')"
+ ).all() as InstanceKeyRow[];
+ } catch (e: unknown) {
+ console.error('[Demo Reset] Failed to read instance API keys:', e instanceof Error ? e.message : e);
+ }
+
// Flush WAL to main DB file
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
@@ -67,6 +80,19 @@ function resetDemoUser(): void {
}
}
+ if (instanceKeys.length) {
+ try {
+ const { db: freshDb } = require('../db/database');
+ const upsert = freshDb.prepare(
+ `INSERT INTO app_settings (key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
+ );
+ for (const row of instanceKeys) upsert.run(row.key, row.value);
+ } catch (e: unknown) {
+ console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e);
+ }
+ }
+
console.log('[Demo Reset] Database restored from baseline');
}
diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts
index 989a7a727..f0eb426a3 100644
--- a/server/src/nest/assignments/assignments.mcp.ts
+++ b/server/src/nest/assignments/assignments.mcp.ts
@@ -115,6 +115,35 @@ export class AssignmentsMcp {
return ok({ assignment });
}
+ @Tool({
+ name: 'set_leg_transport_mode',
+ description: 'Set the travel mode of a route leg for a place assignment. Use direction "outgoing" (default) for the common case: the leg leaving this stop toward the next. Use direction "incoming" ONLY when this stop\'s arriving leg originates from something that is not itself a place assignment (e.g. a flight/train booking arrival, or a morning hotel departure) – setting "incoming" on an ordinary place-to-place leg is stored but has no effect on route rendering, because it targets a column that is only read for non-place origins. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:/". Any other value is stored but drawn as a driving route. null clears it so the leg inherits the day default.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ assignmentId: z.number().int().positive(),
+ transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to inherit the day default'),
+ direction: z.enum(['outgoing', 'incoming']).default('outgoing').describe('Which leg to set: "outgoing" (leaving this stop, default) or "incoming" (arriving at it)'),
+ },
+ annotations: TOOL_ANNOTATIONS_WRITE,
+ access: { group: 'places', mode: 'write' },
+ })
+ async setLegTransportMode(
+ { tripId, assignmentId, transport_mode, direction }: {
+ tripId: number; assignmentId: number; transport_mode?: string | null; direction?: 'outgoing' | 'incoming';
+ },
+ ctx: McpContext,
+ ) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
+ if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
+ if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
+ const assignment = direction === 'incoming'
+ ? this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
+ : this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
+ this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
+ return ok({ assignment });
+ }
+
@Tool({
name: 'move_assignment',
description: 'Move a place assignment to a different day.',
diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts
index dd66a8d6e..0bbfd2acf 100644
--- a/server/src/nest/atlas/atlas.service.ts
+++ b/server/src/nest/atlas/atlas.service.ts
@@ -14,8 +14,19 @@ import {
} from './atlas-geo';
import { KNOWN_COUNTRIES } from './known-countries';
import { cityFromAddress } from './city-from-address';
+import { transferEndpointIds } from './transfer-endpoints';
+import type { FlightEndpointRow } from './transfer-endpoints';
import { haversineKm } from '../common/geo';
+/**
+ * A reservation endpoint plus the two booking columns that decide whether it may
+ * take part in layover pairing at all (see transfer-endpoints.ts).
+ */
+type EndpointRow = FlightEndpointRow & {
+ reservation_type: string | null;
+ reservation_status: string | null;
+};
+
export type CreateBucketData = {
name: string;
lat?: number | null;
@@ -314,19 +325,33 @@ export class AtlasService {
// via resolvePlaceCountries above and would otherwise be missed (#1366).
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
// connection/layover (e.g. a plane change) the traveler never really visited.
- const endpoints = this.db
+ const endpointRows = this.db
.prepare(
`
- SELECT DISTINCT r.trip_id, e.lat, e.lng
+ SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
+ r.type AS reservation_type, r.status AS reservation_status,
+ CASE e.role
+ WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
+ ELSE COALESCE(r.reservation_time, r.reservation_end_time)
+ END AS fallback_time
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')}) AND e.role IN ('from', 'to')
`,
)
- .all(...tripIds) as { trip_id: number; lat: number; lng: number }[];
+ .all(...tripIds) as EndpointRow[];
- // Selecting trip_id makes the DISTINCT per trip, so the same airport comes back once
- // per booking. Collapse to one entry per coordinate before resolving countries —
+ // A layover the traveler never left is only marked 'stop' while both its legs sit in
+ // one booking. Split over two bookings it is a plain 'to' plus 'from' at the same
+ // airport, so it has to be recognised from the times instead (#1535). Only flights
+ // pair: a rental car picked up where a flight landed has its own from/to there and
+ // must keep contributing its country (#1366).
+ const transfers = transferEndpointIds(
+ endpointRows.filter((e) => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled'),
+ );
+ const endpoints = endpointRows.filter((e) => !transfers.has(e.id));
+
+ // Collapse to one entry per coordinate before resolving countries —
// getCountryFromCoords is a point-in-polygon scan and used to run once per point.
const endpointStatus = new Map();
for (const e of endpoints) {
@@ -962,9 +987,16 @@ export class AtlasService {
// planned place there) was never counted as visited (#1366). Resolve each endpoint
// coordinate to a country and fold it in too.
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
- // connection/layover (e.g. a plane change) the traveler never really visited (#1486).
- const endpoints = this.db.all<{ lat: number; lng: number }>(`
- SELECT DISTINCT e.lat, e.lng
+ // connection/layover (e.g. a plane change) the traveler never really visited (#1486),
+ // and the same layover split over two bookings is recognised from its ground time
+ // instead, because there it is stored as a legitimate 'to'/'from' pair (#1535).
+ const endpointRows = this.db.all(`
+ SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
+ r.type AS reservation_type, r.status AS reservation_status,
+ CASE e.role
+ WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
+ ELSE COALESCE(r.reservation_time, r.reservation_end_time)
+ END AS fallback_time
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
JOIN trips t ON r.trip_id = t.id
@@ -973,7 +1005,18 @@ export class AtlasService {
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
AND COALESCE(t.start_date, t.end_date) <= date('now')
`, userId, userId);
- for (const e of endpoints) {
+ const transfers = transferEndpointIds(
+ endpointRows.filter(e => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled')
+ );
+ // The DISTINCT no longer collapses per coordinate now that the endpoint id has to be
+ // in the projection, so collapse here instead: getCountryFromCoords is a
+ // point-in-polygon scan and must stay at one run per coordinate.
+ const endpointCoords = new Map();
+ for (const e of endpointRows) {
+ if (transfers.has(e.id)) continue;
+ endpointCoords.set(`${e.lat},${e.lng}`, { lat: e.lat, lng: e.lng });
+ }
+ for (const e of endpointCoords.values()) {
const code = getCountryFromCoords(e.lat, e.lng);
if (code) countryCodes.add(code.toUpperCase());
}
diff --git a/server/src/nest/atlas/transfer-endpoints.ts b/server/src/nest/atlas/transfer-endpoints.ts
new file mode 100644
index 000000000..43ea304eb
--- /dev/null
+++ b/server/src/nest/atlas/transfer-endpoints.ts
@@ -0,0 +1,305 @@
+/**
+ * Layover detection for the two country-deriving Atlas queries.
+ *
+ * A plane change is not a visit, so #1486 taught both queries to skip
+ * reservation_endpoints rows with role='stop'. That only reaches a layover
+ * stored INSIDE one booking. When the legs of one journey land in the database as
+ * two separate reservations (an AirTrail import whose flights never chained, or
+ * legs imported one by one before the join feature existed), the hub is the
+ * legitimate 'to' of the first booking and the legitimate 'from' of the second,
+ * and no role filter can tell that apart from a real destination (#1535).
+ *
+ * What does tell it apart is the time on the ground: leaving the same airport
+ * again within 24 h is a connection, not a stay. The window is deliberately the
+ * one both importers already apply when they merge the legs of one journey
+ * (orderConnectionChain in airtrail-import.service.ts, kitinerary-mapper.ts), so
+ * Atlas and the importers agree on what counts as one connection.
+ *
+ * Read-side only, hence no migration: both queries derive their countries live on
+ * every request, so a database that already holds the split bookings heals as
+ * soon as this ships.
+ */
+import { haversineKm } from '../common/geo';
+
+/** Ground time up to which an onward departure is a connection rather than a stay. */
+export const MAX_LAYOVER_MS = 24 * 3600 * 1000;
+
+/**
+ * Without clocks only calendar dates are left. Leaving the day after arriving is
+ * still an overnight connection; a wider gap can't be told apart from a genuine
+ * stopover in the stored data, so it keeps counting as visited.
+ */
+export const MAX_CLOCKLESS_DAY_GAP = 1;
+
+/**
+ * How far from where the arriving leg started the departing leg may land and
+ * still count as the flight home rather than the next hop. A return is regularly
+ * booked into the sibling airport of the same city (Charleroi instead of
+ * Zaventem, Gatwick instead of Heathrow), and that day was spent at the
+ * destination, not in transit.
+ *
+ * A radius is the right shape for that question: the country of a coordinate
+ * would be both too coarse (Belfast to Dublin to London would read as a return
+ * and start counting Ireland as visited) and too expensive, since
+ * getCountryFromCoords scans admin polygons and this runs inside the pairing
+ * loop. 100 km covers every sibling pair worth naming (CRL/BRU 45 km, LGW/LHR
+ * 40 km, EWR/JFK 34 km, HND/NRT 60 km) and stays below the distance between two
+ * cities that each own their traffic, so Amsterdam to Frankfurt to Brussels
+ * (170 km) keeps pairing as the connection it is.
+ */
+const RETURN_RADIUS_KM = 100;
+
+/** One reservation_endpoints row, joined to the columns its booking contributes. */
+export interface FlightEndpointRow {
+ id: number;
+ reservation_id: number;
+ trip_id: number;
+ role: string;
+ code: string | null;
+ lat: number;
+ lng: number;
+ local_date: string | null;
+ local_time: string | null;
+ /** The booking's own time column for this side of the leg, used for the date only. */
+ fallback_time: string | null;
+}
+
+/** Local calendar date plus, when the leg carried an instant, the local clock. */
+interface Stamp {
+ date: string | null;
+ time: string | null;
+}
+
+/** Where one end of a booking actually is, for the airports the importer geocoded. */
+interface Coords {
+ lat: number;
+ lng: number;
+}
+
+/** The match keys and positions of one booking's own two ends, shared by all of its endpoints. */
+interface BookingKeys {
+ from: Set;
+ to: Set;
+ fromCoords: Coords[];
+ toCoords: Coords[];
+}
+
+interface Endpoint {
+ row: FlightEndpointRow;
+ booking: BookingKeys;
+ /** The buckets this endpoint belongs to, already scoped to its trip. */
+ keys: string[];
+ /** Midnight of the leg's local date. */
+ dayMs: number;
+ /** The leg's exact instant, null when it carried no clock. */
+ exactMs: number | null;
+ /** What a bucket of departures is ordered by: the instant if there is one, else the day. */
+ sortMs: number;
+}
+
+const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
+const DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
+const CLOCK = /^\d{2}:\d{2}/;
+const DAY_MS = 24 * 3600 * 1000;
+
+/**
+ * How far past the arrival's midnight the sorted departures still have to be
+ * walked. Two days, because each half of a pair is anchored on its own date: a
+ * clockless arrival still pairs with a departure at 23:45 the next day, and an
+ * arrival at 23:50 still pairs with a date-only departure the day after. This
+ * bounds the scan only, isConnection stays the one place that decides.
+ */
+const SCAN_WINDOW_MS = 2 * DAY_MS;
+
+/**
+ * Every key an endpoint can be matched on. Two rows for one airport need not
+ * agree on their shape: an imported leg carries the IATA/ICAO code, a booking
+ * typed in by hand may only have been geocoded. Indexing both and pairing on
+ * either match covers the mixed case, and the coordinate key is rounded because
+ * two geocodes of the same airport differ in the last decimals.
+ */
+function endpointKeys(e: FlightEndpointRow): string[] {
+ const keys: string[] = [];
+ const code = e.code?.trim();
+ if (code) keys.push(`c:${code.toUpperCase()}`);
+ if (Number.isFinite(e.lat) && Number.isFinite(e.lng)) keys.push(`p:${e.lat.toFixed(2)},${e.lng.toFixed(2)}`);
+ return keys;
+}
+
+/**
+ * An endpoint only has local_date/local_time when its leg carried a usable
+ * instant; localParts() returns nulls otherwise, which is exactly the date-only
+ * AirTrail flight that fails to chain in the first place. So fall back to the
+ * booking's own time column, but take its DATE half only: for an arrival that
+ * column can hold the departure airport's local clock (airtrail.mapper.ts), which
+ * says nothing about the time on the ground here.
+ */
+function stampOf(e: FlightEndpointRow): Stamp {
+ if (e.local_date && DATE_ONLY.test(e.local_date)) {
+ const time = e.local_time && CLOCK.test(e.local_time) ? e.local_time.slice(0, 5) : null;
+ return { date: e.local_date, time };
+ }
+ const fallback = e.fallback_time?.match(DATE_PREFIX);
+ return { date: fallback ? fallback[1] : null, time: null };
+}
+
+function utcMs(date: string, time: string | null): number {
+ return Date.parse(`${date}T${time ?? '00:00'}:00Z`);
+}
+
+/**
+ * The instants the pairing compares, read once per endpoint instead of once per
+ * pair. null means the leg can never be either half of a pair: without a date
+ * that parses, neither branch below has anything to measure.
+ *
+ * A clock that got past the shape check can still be nonsense, and the resulting
+ * NaN is kept rather than downgraded to "no clock": a pair where both sides claim
+ * an instant has always been judged on the instant alone.
+ */
+function instantsOf(stamp: Stamp): { dayMs: number; exactMs: number | null; sortMs: number } | null {
+ if (!stamp.date) return null;
+ const dayMs = utcMs(stamp.date, null);
+ if (!Number.isFinite(dayMs)) return null;
+ const exactMs = stamp.time === null ? null : utcMs(stamp.date, stamp.time);
+ return { dayMs, exactMs, sortMs: exactMs !== null && Number.isFinite(exactMs) ? exactMs : dayMs };
+}
+
+/**
+ * Is the departure the onward leg of the arrival? Both endpoints sit at the same
+ * airport and therefore in the same timezone, so the naive local stamps compare
+ * as real instants and their difference is the true time on the ground. That is
+ * the only reason this is sound, so never reuse it across two airports.
+ */
+function isConnection(arrival: Endpoint, departure: Endpoint): boolean {
+ if (arrival.exactMs !== null && departure.exactMs !== null) {
+ const gap = departure.exactMs - arrival.exactMs;
+ return Number.isFinite(gap) && gap >= 0 && gap <= MAX_LAYOVER_MS;
+ }
+ const days = (departure.dayMs - arrival.dayMs) / DAY_MS;
+ return days >= 0 && days <= MAX_CLOCKLESS_DAY_GAP;
+}
+
+function intersects(a: Set, b: Set): boolean {
+ for (const key of a) {
+ if (b.has(key)) return true;
+ }
+ return false;
+}
+
+/**
+ * Is the departing booking heading back to where the arriving one started? Then
+ * the traveler spent the time in between at the destination, and the same call is
+ * the one orderConnectionChain makes before it joins two legs.
+ *
+ * The airport keys answer that for a return into the exact same airport; the
+ * distance covers the sibling airport of the same city, which the keys can never
+ * match. Legs the importer had no coordinates for fall back to the key match.
+ */
+function returnsToOrigin(departure: Endpoint, arrival: Endpoint): boolean {
+ if (intersects(departure.booking.to, arrival.booking.from)) return true;
+ for (const landed of departure.booking.toCoords) {
+ for (const started of arrival.booking.fromCoords) {
+ if (haversineKm(landed.lat, landed.lng, started.lat, started.lng) <= RETURN_RADIUS_KM) return true;
+ }
+ }
+ return false;
+}
+
+/** First entry of a bucket sorted by sortMs that is not earlier than `from`. */
+function firstFrom(bucket: Endpoint[], from: number): number {
+ let low = 0;
+ let high = bucket.length;
+ while (low < high) {
+ const mid = (low + high) >>> 1;
+ if (bucket[mid].sortMs < from) low = mid + 1;
+ else high = mid;
+ }
+ return low;
+}
+
+/**
+ * The endpoint ids that are only a connection airport, spread over two bookings.
+ *
+ * Callers pass flight endpoints of non-cancelled bookings only: a car rental
+ * picked up right where a flight landed has its own from/to at that airport and
+ * would otherwise pair with the arrival and swallow the destination country
+ * (#1366).
+ */
+export function transferEndpointIds(rows: FlightEndpointRow[]): Set {
+ const transfers = new Set();
+ if (rows.length < 2) return transfers;
+
+ const landings: Endpoint[] = [];
+ const departures = new Map();
+ const bookings = new Map();
+
+ for (const row of rows) {
+ if (row.role !== 'from' && row.role !== 'to') continue;
+ const keys = endpointKeys(row);
+ if (keys.length === 0) continue;
+
+ let booking = bookings.get(row.reservation_id);
+ if (!booking) {
+ booking = { from: new Set(), to: new Set(), fromCoords: [], toCoords: [] };
+ bookings.set(row.reservation_id, booking);
+ }
+ const arriving = row.role === 'to';
+ const ownKeys = arriving ? booking.to : booking.from;
+ for (const key of keys) ownKeys.add(key);
+ if (Number.isFinite(row.lat) && Number.isFinite(row.lng)) {
+ (arriving ? booking.toCoords : booking.fromCoords).push({ lat: row.lat, lng: row.lng });
+ }
+
+ // A leg without a readable date pairs with nothing, but its keys and its
+ // position had to be recorded above: they describe the booking either way.
+ const instants = instantsOf(stampOf(row));
+ if (!instants) continue;
+
+ // Two trips that happen to touch the same airport are two journeys: an
+ // arrival home from one and a departure on the next is not a layover. The
+ // trip belongs in the bucket key rather than in a per-pair check, so the
+ // journeys of an imported flight log are never compared against each other.
+ const endpoint: Endpoint = { row, booking, keys: keys.map((key) => `${row.trip_id}:${key}`), ...instants };
+ if (arriving) {
+ landings.push(endpoint);
+ continue;
+ }
+ for (const key of endpoint.keys) {
+ const bucket = departures.get(key);
+ if (bucket) bucket.push(endpoint);
+ else departures.set(key, [endpoint]);
+ }
+ }
+
+ // In time order, so an arrival can stop looking once the ground time is out of
+ // reach. Ten years of flight log imported into one trip put hundreds of
+ // departures on a home airport, and comparing every arrival against all of them
+ // cost more than a second on both request paths that call this.
+ for (const bucket of departures.values()) bucket.sort((a, b) => a.sortMs - b.sortMs);
+
+ for (const arrival of landings) {
+ const until = arrival.dayMs + SCAN_WINDOW_MS;
+ // An endpoint carrying both a code and a coordinate sits in two buckets, so
+ // without this the same pair would be judged twice.
+ const seen = arrival.keys.length > 1 ? new Set() : null;
+ for (const key of arrival.keys) {
+ const left = departures.get(key);
+ if (!left) continue;
+ for (let i = firstFrom(left, arrival.dayMs); i < left.length; i++) {
+ const departure = left[i];
+ if (departure.sortMs > until) break;
+ if (seen) {
+ if (seen.has(departure)) continue;
+ seen.add(departure);
+ }
+ if (departure.row.reservation_id === arrival.row.reservation_id) continue;
+ if (returnsToOrigin(departure, arrival)) continue;
+ if (!isConnection(arrival, departure)) continue;
+ transfers.add(arrival.row.id);
+ transfers.add(departure.row.id);
+ }
+ }
+ }
+
+ return transfers;
+}
diff --git a/server/src/nest/audit/audit.service.ts b/server/src/nest/audit/audit.service.ts
index e6de049be..8e6fe9746 100644
--- a/server/src/nest/audit/audit.service.ts
+++ b/server/src/nest/audit/audit.service.ts
@@ -11,6 +11,7 @@ const ACTION_LABELS: Record = {
'user.mfa_enable': 'enabled MFA',
'user.mfa_disable': 'disabled MFA',
'settings.app_update': 'updated settings',
+ 'settings.api_keys_update': 'updated API keys',
'trip.create': 'created trip',
'trip.delete': 'deleted trip',
'admin.user_role_change': 'changed user role',
@@ -38,6 +39,12 @@ function buildInfoSummary(action: string, details?: Record): st
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
return parts.length ? ` (${parts.join(', ')})` : '';
}
+ if (action === 'settings.api_keys_update') {
+ // The names, read straight out of details — the writer puts nothing else in
+ // there, and a key value must never reach a log line.
+ const changed = Array.isArray(details.changed) ? details.changed : [];
+ return changed.length ? ` (${changed.join(', ')})` : '';
+ }
if (action === 'immich.private_ip_configured') {
return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : '';
}
diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts
index ca31d5561..e63a732df 100644
--- a/server/src/nest/auth/auth.controller.ts
+++ b/server/src/nest/auth/auth.controller.ts
@@ -117,22 +117,51 @@ export class AuthController {
return { success: true };
}
+ /**
+ * One row per save that actually changed a key, on all three routes that can
+ * write these columns (#1939 asked for it: the API keys were the only admin
+ * setting whose change left no trace, while `settings.app_update` next door
+ * has always been logged).
+ *
+ * Names only. `details` is rendered as raw JSON in the admin audit panel and
+ * mirrored into the debug log, so a value here would leak the key twice over.
+ * Nothing is written when nothing changed — the panel saves before every test
+ * click, and each click would otherwise cost a log line.
+ */
+ private auditApiKeys(userId: number, changed: string[], req: Request): void {
+ if (!changed.length) return;
+ this.audit.writeAudit({
+ userId,
+ action: 'settings.api_keys_update',
+ resource: 'api_keys',
+ ip: getClientIp(req),
+ details: { changed },
+ });
+ }
+
@Put('me/maps-key')
- mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto) {
- return this.profile.updateMapsKey(user.id, body.maps_api_key);
+ mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
+ // changedKeys is for the audit line, not for the client: destructured off so
+ // the response body stays what it always was.
+ const { changedKeys = [], ...result } = this.profile.updateMapsKey(user.id, body.maps_api_key);
+ this.auditApiKeys(user.id, changedKeys, req);
+ return result;
}
@Put('me/api-keys')
- apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto) {
- return this.profile.updateApiKeys(user.id, body);
+ apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
+ const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body);
+ this.auditApiKeys(user.id, changedKeys, req);
+ return result;
}
@Put('me/settings')
- updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto) {
+ updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
const result = this.profile.updateSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
+ this.auditApiKeys(user.id, result.changedKeys ?? [], req);
return { success: result.success, user: result.user };
}
diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts
index 6d0141898..a18dab305 100644
--- a/server/src/nest/auth/auth.service.ts
+++ b/server/src/nest/auth/auth.service.ts
@@ -14,6 +14,7 @@ import { PermissionsService } from '../permissions/permissions.service';
import { validatePassword } from '../common/passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
+import { resolveApiKey } from '../settings/instance-api-keys';
import { EphemeralTokenService } from './ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
// the whole tools fan-out (and via the domain bridges, the Nest services) into
@@ -236,13 +237,12 @@ export class AuthService {
// One directory deeper than the legacy src/services location — the extra
// '../' keeps resolving to the workspace package.json.
const version: string = readEnv().app.appVersion ?? require('../../../package.json').version;
- // The operator's key counts too. getMapsKey prefers it over anything in the
- // users table, so asking the table alone would report "no key" on an install
- // where every lookup works, and the client would hide the Google features it
- // is actually able to use.
- const hasGoogleKey =
- !!readEnv().maps.placesApiKey ||
- !!this.db.get("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1");
+ // Asked through the same resolver the search itself uses, so the client can
+ // never show Google features that rest on a key this caller does not get —
+ // nor hide them from a member who does have one (#1939). Unauthenticated the
+ // question is only about the instance, which is the first two steps of the
+ // chain; id 0 matches no row.
+ const hasGoogleKey = !!resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey).key;
const oidcDisplayName = readEnv().oidc.displayName ||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
const oidcConfigured = !!(
diff --git a/server/src/nest/auth/user-profile.service.ts b/server/src/nest/auth/user-profile.service.ts
index 97ad8a6af..8276e7a36 100644
--- a/server/src/nest/auth/user-profile.service.ts
+++ b/server/src/nest/auth/user-profile.service.ts
@@ -6,6 +6,14 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
import { avatarUrl } from '../common/avatarUrl';
import { EMAIL_REGEX, mask_stored_api_key } from './auth.helpers';
import { splitManagedKeys, MANAGED_LOCKED_PROFILE_KEYS } from '../common/managed';
+import {
+ INSTANCE_API_KEY_NAMES,
+ readInstanceApiKey,
+ resolveApiKey,
+ writeInstanceApiKey,
+ type InstanceApiKeyName,
+} from '../settings/instance-api-keys';
+import { SEARCH_TEXT_FIELD_MASK } from '../maps/maps.helpers';
import { User } from '../../types';
/**
@@ -43,33 +51,109 @@ export class UserProfileService {
return readEnv().managed.enabled;
}
+ /** The three key columns plus the role that decides where a save lands. */
+ private currentKeys(userId: number) {
+ return this.db.get>(
+ 'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
+ userId
+ );
+ }
+
+ /**
+ * The value a save is measured against, in cleartext.
+ *
+ * For an admin and one of the two instance-wide names that is the instance
+ * value, because that is what the panel shows them and what the search uses;
+ * their own column only speaks when no instance value has been set yet.
+ */
+ private storedKeyPlaintext(
+ name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key',
+ current: Pick | undefined,
+ isAdmin: boolean,
+ ): string {
+ if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
+ const instance = readInstanceApiKey(this.db, name as InstanceApiKeyName);
+ if (instance !== null) return instance;
+ }
+ return decrypt_api_key(current?.[name]) ?? '';
+ }
+
+ /**
+ * Which key names this body actually changes.
+ *
+ * Compared on cleartext, never on what is stored: the IV is random, so the
+ * same key encrypted twice differs every time and a ciphertext comparison
+ * would report a change on every save. The panel saves before each test click,
+ * so that difference is the one between an audit trail and a full log.
+ *
+ * `skipped` are the names a managed install refuses to write — auditing them
+ * would claim a change that never happened.
+ */
+ private changedKeyNames(
+ body: Record,
+ current: Pick | undefined,
+ isAdmin: boolean,
+ skipped: string[] = [],
+ ): string[] {
+ const norm = (v: unknown) => String(v ?? '').trim();
+ return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key'] as const).filter(
+ (name) => body[name] !== undefined && !skipped.includes(name) && norm(body[name]) !== norm(this.storedKeyPlaintext(name, current, isAdmin))
+ );
+ }
+
+ /**
+ * Mirror the two instance-wide names into app_settings when an admin saves.
+ *
+ * Only an admin: `PUT /me/api-keys` is not admin-gated (the class carries
+ * JwtAuthGuard alone), so letting any caller write here would hand every
+ * member the instance credential. A non-admin keeps writing their own column,
+ * which is still the last step of the resolver.
+ */
+ private mirrorInstanceKeys(body: Record, isAdmin: boolean): void {
+ if (!isAdmin) return;
+ for (const name of INSTANCE_API_KEY_NAMES) {
+ if (body[name] !== undefined) writeInstanceApiKey(this.db, name, body[name]);
+ }
+ }
+
updateMapsKey(userId: number, key: unknown) {
const maps_api_key = key as string | null | undefined;
if (this.managed) {
- return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'] };
+ return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'], changedKeys: [] };
}
- this.db.run(
- 'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
- maybe_encrypt_api_key(maps_api_key), userId
- );
- return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) };
+ const current = this.currentKeys(userId);
+ const isAdmin = current?.role === 'admin';
+ const changedKeys = this.changedKeyNames({ maps_api_key }, current, isAdmin);
+ this.db.transaction(() => {
+ this.db.run(
+ 'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
+ maybe_encrypt_api_key(maps_api_key), userId
+ );
+ this.mirrorInstanceKeys({ maps_api_key }, isAdmin);
+ });
+ return { success: true, maps_api_key: mask_stored_api_key(maps_api_key), changedKeys };
}
updateApiKeys(userId: number, rawBody: unknown) {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string };
const { blocked } = splitManagedKeys(body, this.managed);
for (const key of blocked) delete body[key as keyof typeof body];
- const current = this.db.get>('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?', userId);
+ const current = this.currentKeys(userId);
+ const isAdmin = current?.role === 'admin';
+ const changedKeys = this.changedKeyNames(body, current, isAdmin, blocked);
- // `?? null` instead of the former non-null assertions: a user row deleted
- // mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
- this.db.run(
- 'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
- body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
- body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
- body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
- userId
- );
+ this.db.transaction(() => {
+ // `?? null` instead of the former non-null assertions: a user row deleted
+ // mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
+ this.db.run(
+ 'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
+ body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
+ body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
+ body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
+ userId
+ );
+ this.mirrorInstanceKeys(body, isAdmin);
+ });
const updated = this.db.get>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
@@ -81,13 +165,14 @@ export class UserProfileService {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
+ changedKeys,
};
}
updateSettings(
userId: number,
rawBody: unknown
- ): { error?: string; status?: number; success?: boolean; user?: Record } {
+ ): { error?: string; status?: number; success?: boolean; user?: Record; changedKeys?: string[] } {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string };
const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
@@ -126,10 +211,19 @@ export class UserProfileService {
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
+ // Read before the write, so the comparison sees the old value; the role in
+ // the same row decides whether the two instance-wide names travel with it.
+ const current = this.currentKeys(userId);
+ const isAdmin = current?.role === 'admin';
+ const changedKeys = keyLocked ? [] : this.changedKeyNames(body, current, isAdmin, blocked);
+
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(userId);
- this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
+ this.db.transaction(() => {
+ this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
+ if (!keyLocked) this.mirrorInstanceKeys(body, isAdmin);
+ });
}
const updated = this.db.get>(
@@ -142,6 +236,7 @@ export class UserProfileService {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
+ changedKeys,
};
}
@@ -168,11 +263,15 @@ export class UserProfileService {
};
}
+ // Maps and Unsplash are read where the search reads them: instance-wide
+ // first. Showing the admin their own column while every request used another
+ // value is the confusion #1939 reported. Their column still answers while no
+ // instance value exists — on that install it is what the resolver picks too.
return {
settings: {
- maps_api_key: decrypt_api_key(user.maps_api_key),
+ maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key),
- unsplash_api_key: decrypt_api_key(user.unsplash_api_key),
+ unsplash_api_key: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
},
};
}
@@ -227,7 +326,7 @@ export class UserProfileService {
// -------------------------------------------------------------------------
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
- const user = this.db.get>('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?', userId);
+ const user = this.db.get>('SELECT role, openweather_api_key FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
const result: {
@@ -243,7 +342,10 @@ export class UserProfileService {
};
} = { maps: false, weather: false, maps_details: null };
- const maps_api_key = decrypt_api_key(user.maps_api_key);
+ // The key a search would actually use, not the one in this admin's column:
+ // testing a value nothing resolves to is how "the panel says the key is
+ // fine" and "every search 403s" coexisted (#1939).
+ const { key: maps_api_key } = resolveApiKey(this.db, 'maps_api_key', userId, readEnv().maps.placesApiKey);
if (maps_api_key) {
try {
// Same Referer as maps.service googleFetch — without it, keys with an
@@ -257,7 +359,9 @@ export class UserProfileService {
...(referer ? { Referer: referer } : {}),
'Content-Type': 'application/json',
'X-Goog-Api-Key': maps_api_key,
- 'X-Goog-FieldMask': 'places.displayName',
+ // The mask the real search sends. A narrower probe passes on keys
+ // that are restricted to fewer Places SKUs than TREK asks for.
+ 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify({ textQuery: 'test' }),
}
diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts
index aa232a945..4dc31f582 100644
--- a/server/src/nest/calendar/calendar.service.ts
+++ b/server/src/nest/calendar/calendar.service.ts
@@ -412,6 +412,14 @@ export class CalendarService {
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
+ // A segment booked under its own reference gets its own line (#1943): at
+ // the gate it is that code the airline asks for, not the booking's. No
+ // leg reference, no extra line, so an existing feed stays byte-identical.
+ for (const leg of meta.legs as { from?: string; to?: string; confirmation_number?: string }[]) {
+ if (!leg.confirmation_number) continue;
+ const segment = [leg.from, leg.to].filter(Boolean).join('-');
+ desc += `\nConfirmation${segment ? ` ${segment}` : ''}: ${leg.confirmation_number}`;
+ }
} else if (meta.departure_airport || meta.arrival_airport) {
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts
index 06959c41e..430099d36 100644
--- a/server/src/nest/days/days.mcp.ts
+++ b/server/src/nest/days/days.mcp.ts
@@ -104,6 +104,30 @@ export class DaysMcp {
return ok({ success: true });
}
+ @Tool({
+ name: 'set_day_default_transport_mode',
+ description: 'Set the whole-day default travel mode for a day. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:/". Any other value is stored but drawn as a driving route. null clears the default. Per-segment leg modes still override this.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to clear the day default'),
+ },
+ annotations: TOOL_ANNOTATIONS_WRITE,
+ access: { group: 'trips', mode: 'write' },
+ })
+ async setDayDefaultTransportMode(
+ { tripId, dayId, transport_mode }: { tripId: number; dayId: number; transport_mode?: string | null },
+ ctx: McpContext,
+ ) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
+ if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
+ if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
+ const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null);
+ this.guards.safeBroadcast(tripId, 'day:updated', { day });
+ return ok({ day });
+ }
+
@ResourceTemplate({
name: 'trip-days',
uriTemplate: 'trek://trips/{tripId}/days',
diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts
index dce34e1d4..7f482fe5e 100644
--- a/server/src/nest/journey/journey-domain.service.ts
+++ b/server/src/nest/journey/journey-domain.service.ts
@@ -14,7 +14,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository';
const JP_SELECT = `
gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
- tp.media_type, tp.duration_ms
+ tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
`;
const JP_JOIN = `journey_entry_photos jep
@@ -24,7 +24,7 @@ const JP_JOIN = `journey_entry_photos jep
const GALLERY_SELECT = `
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
- tp.media_type, tp.duration_ms
+ tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
`;
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
@@ -342,6 +342,10 @@ export class JourneyDomainService {
// (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped
// path enforces.
if (!this.db.canAccessTrip(tripId, userId)) return false;
+ // And a journey the caller can actually reach. Without this, any logged-in user
+ // could link a trip of theirs into a stranger's journey and seed entries and
+ // photos there — the MCP tool has always checked this, the REST route never did.
+ if (!this.canAccessJourney(journeyId, userId)) return false;
const now = this.ts();
try {
this.db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run(
@@ -355,8 +359,12 @@ export class JourneyDomainService {
// sync skeleton entries for all places in this trip
this.syncTripPlaces(journeyId, tripId, userId);
- // import existing trip photos (Immich/Synology) with sharing settings
- this.syncTripPhotos(journeyId, tripId);
+ // Trip photos are deliberately NOT pulled in any more (#1614). Photos live in
+ // journeys now: the trip-photo surface lost its UI in 3.1.0, nothing writes to
+ // it on an install newer than that, and copying rows between the two was what
+ // let a photo one member had chosen not to share reach a journey at all. The
+ // table and its (unreferenced) routes stay for one more release rather than
+ // being dropped in an append-only migration.
this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
return true;
}
@@ -441,29 +449,6 @@ export class JourneyDomainService {
}
}
- // import trip_photos into journey gallery when a trip is linked
- private syncTripPhotos(journeyId: number, tripId: number) {
- const tripPhotos = this.db
- .prepare('SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?')
- .all(tripId) as { photo_id: number; shared: number }[];
- if (!tripPhotos.length) return;
-
- const now = this.ts();
- const maxOrderRow = this.db
- .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?')
- .get(journeyId) as { m: number | null };
- let nextOrder = (maxOrderRow?.m ?? -1) + 1;
-
- for (const tp of tripPhotos) {
- this.db.prepare(
- `
- INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
- VALUES (?, ?, ?, ?, ?)
- `,
- ).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
- }
- }
-
// called when a trip place is created
onPlaceCreated(tripId: number, placeId: number) {
const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as {
diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts
index 2aefb766d..56a7ec1c9 100644
--- a/server/src/nest/journey/journey-share.service.ts
+++ b/server/src/nest/journey/journey-share.service.ts
@@ -7,6 +7,8 @@ interface JourneySharePermissions {
share_timeline?: boolean;
share_gallery?: boolean;
share_map?: boolean;
+ /** Read the journey newest-first, like a blog, rather than in trip order. */
+ newest_first?: boolean;
}
interface JourneyShareTokenInfo {
@@ -15,6 +17,7 @@ interface JourneyShareTokenInfo {
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
+ newest_first: boolean;
}
/**
@@ -45,18 +48,19 @@ export class JourneyShareService {
share_timeline = true,
share_gallery = true,
share_map = true,
+ newest_first = false,
} = permissions;
const existing = this.db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
if (existing) {
- this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
- .run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
+ this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ?, newest_first = ? WHERE journey_id = ?')
+ .run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0, journeyId);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
- this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
- .run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
+ this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map, newest_first) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0);
return { token, created: true };
}
@@ -69,6 +73,7 @@ export class JourneyShareService {
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
+ newest_first: !!row.newest_first,
};
}
@@ -130,7 +135,7 @@ export class JourneyShareService {
const photos = this.db.prepare(`
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
- tkp.media_type, tkp.duration_ms
+ tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tkp ON tkp.id = gp.photo_id
@@ -146,7 +151,7 @@ export class JourneyShareService {
const gallery = this.db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
- tkp.media_type, tkp.duration_ms
+ tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
@@ -183,6 +188,7 @@ export class JourneyShareService {
const projected: Record = { ...e };
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
if (!shareGallery) projected.photos = [];
+ else if (!shareMap) projected.photos = stripPhotoGps(e.photos);
return projected;
});
} else if (shareMap) {
@@ -208,13 +214,23 @@ export class JourneyShareService {
status: journey.status,
},
entries: publicEntries,
- gallery: shareGallery ? gallery : [],
+ // A photo now carries the coordinates it was taken at, which is a location the
+ // owner never typed and may not expect to publish. It follows share_map, the
+ // same switch the entry coordinates follow — otherwise a gallery-only share
+ // would hand out places the map was deliberately turned off for.
+ gallery: shareGallery ? (shareMap ? gallery : stripPhotoGps(gallery)) : [],
stats,
permissions: {
share_timeline: shareTimeline,
share_gallery: shareGallery,
share_map: shareMap,
+ newest_first: !!row.newest_first,
},
};
}
}
+
+/** Drop capture coordinates from a photo list, keeping everything else. */
+function stripPhotoGps(photos: T[] | undefined | null): T[] {
+ return (photos ?? []).map(p => ({ ...(p as Record), lat: null, lng: null })) as T[];
+}
diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts
index a05633bd3..3e9a89c27 100644
--- a/server/src/nest/journey/journey.controller.ts
+++ b/server/src/nest/journey/journey.controller.ts
@@ -25,6 +25,7 @@ import type { User } from '../../types';
import { StorageService } from '../storage/storage.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
+import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { ADDON_IDS } from '../../addons';
@@ -126,6 +127,7 @@ export class JourneyController {
constructor(
private readonly journey: JourneyService,
private readonly storage: StorageService,
+ private readonly captureBackfill: PhotoCaptureBackfillService,
) {}
/**
@@ -147,6 +149,16 @@ export class JourneyController {
}
}
+ // The add call carries only an asset id, so when and where the picture was taken
+ // are fetched from the provider afterwards rather than trusted from the client
+ // (#1614). Detached: a slow or unreachable provider must not hold up the add.
+ private backfillCapture(photos: unknown[], userId: number): void {
+ const ids = photos
+ .map(p => (p as { photo_id?: number } | null)?.photo_id)
+ .filter((id): id is number => typeof id === 'number');
+ this.captureBackfill.schedule(ids, userId);
+ }
+
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
list(@CurrentUser() user: User) {
@@ -226,6 +238,7 @@ export class JourneyController {
if (!results.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
+ this.backfillCapture(results, user.id);
return { photos: results };
}
@@ -239,6 +252,7 @@ export class JourneyController {
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
if (photo) added.push(photo);
});
+ this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
@@ -248,6 +262,7 @@ export class JourneyController {
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
+ this.backfillCapture([photo], user.id);
return photo;
}
@@ -332,6 +347,9 @@ export class JourneyController {
if (!photos.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
+ // An uploaded file carries its own EXIF; reading it is what puts the photo on
+ // the map later. Detached, like the provider branch.
+ this.backfillCapture(photos, user.id);
return { photos };
}
@@ -390,6 +408,7 @@ export class JourneyController {
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
if (photo) added.push(photo);
});
+ this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
@@ -399,6 +418,7 @@ export class JourneyController {
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
+ this.backfillCapture([photo], user.id);
return photo;
}
diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts
index 69d4b25fe..dffbf5427 100644
--- a/server/src/nest/maps/maps.helpers.ts
+++ b/server/src/nest/maps/maps.helpers.ts
@@ -22,6 +22,17 @@ export function buildUserAgent(instanceUrl: string | undefined): string {
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
export const UA = buildUserAgent(getAppUrl());
+/**
+ * The fields places:searchText is asked for.
+ *
+ * Here rather than beside the call because the admin panel's key test has to
+ * send the same mask: a key restricted to a narrower set of Places SKUs answers
+ * a one-field probe with 200 and the real search with 403, which is the second
+ * way to reach the #1939 report ("test button green, searching fails").
+ */
+export const SEARCH_TEXT_FIELD_MASK =
+ 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus';
+
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
@@ -118,17 +129,29 @@ export function namesOverlap(a: string, b: string): boolean {
}
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
+// The same id inside the `data=` pathname blob, where a resolved share link keeps it:
+// /maps/place//data=!4m6!3m5!1s0x47e66e1f06e2b70f:0x40b82c3688c9460!8m2!3d48.85!4d2.29
+const GOOGLE_FTID_DATA_RE = /!1s(0x[0-9a-f]{1,20}:0x[0-9a-f]{1,20})/i;
-// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param.
+// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL.
+// Two shapes carry it. The `?ftid=` query parameter is the old one. A share link that
+// resolveGoogleMapsUrl has followed to its final hop carries it instead in the `data=`
+// pathname blob, in the same `!1s…!3d…!4d…` run the coordinates come from (#1954) — so
+// reading only the query parameter dropped the id for every pasted maps.app.goo.gl link.
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
-// with no ftid, so this returns null for most API responses — the precise query_place_id link is
-// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link
-// resolved by resolveGoogleMapsUrl or a Google MyMaps list import.
+// with neither, so this still returns null for most API responses — the precise
+// query_place_id link is used instead.
export function googleFtidFromMapsUrl(url?: string | null): string | null {
if (!url) return null;
try {
- const ftid = new URL(url).searchParams.get('ftid')?.trim();
- return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
+ const parsed = new URL(url);
+ const ftid = parsed.searchParams.get('ftid')?.trim();
+ if (ftid && GOOGLE_FTID_RE.test(ftid)) return ftid.toLowerCase();
+ // Only for a place URL. A /maps/dir/ route carries one !1s per waypoint and the
+ // first one is the origin, not the place the link is about.
+ if (!parsed.pathname.includes('/place/')) return null;
+ const fromPath = GOOGLE_FTID_DATA_RE.exec(parsed.pathname)?.[1];
+ return fromPath ? fromPath.toLowerCase() : null;
} catch {
return null;
}
diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts
index ecf6dc06b..10362a3a9 100644
--- a/server/src/nest/maps/maps.service.ts
+++ b/server/src/nest/maps/maps.service.ts
@@ -9,13 +9,14 @@ import type {
} from '@trek/shared';
import { readEnv, getAppUrl } from '../../app-config';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
-import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
+import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { DatabaseService } from '../database/database.service';
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
import {
UA,
+ SEARCH_TEXT_FIELD_MASK,
toApiLang,
googleFtidFromMapsUrl,
buildOsmDetails,
@@ -59,6 +60,18 @@ function placesEndpoint(endpoint: string): string {
return base.replace(/\/+$/, '') + endpoint.slice(PLACES_UPSTREAM.length);
}
+/**
+ * Says which of the three credentials Google rejected, never which value.
+ *
+ * The response body Google sends ("The caller does not have permission") is
+ * identical whichever key was used, so without this line a report of "works for
+ * the admin, fails for everyone else" cannot be told apart from a genuinely
+ * broken key.
+ */
+function logKeyFailure(label: string, status: number, userId: number, source: ApiKeySource | null): void {
+ console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`);
+}
+
function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise {
const endpoint = placesEndpoint(rawEndpoint);
googleApiCallCount++;
@@ -548,25 +561,25 @@ export class MapsService {
// ── API key retrieval ──────────────────────────────────────────────────────
- getMapsKey(userId: number): string | null {
- // When the operator supplies the credential, the database is not asked at
- // all. Two reasons, and the second is the load-bearing one: a per-user key
- // would route around whatever the operator's endpoint counts, and the key
- // columns are readable by any admin of this instance, so a shared key stored
- // there would be a key handed out. Unset, this branch never runs.
- const operatorKey = readEnv().maps.placesApiKey;
- if (operatorKey) return operatorKey;
+ /**
+ * The Places credential for this request, and where it came from.
+ *
+ * Operator env first: a per-user key would route around whatever the
+ * operator's endpoint counts, and unset, that branch never runs. Then the
+ * instance-wide value the admin panel writes, then the caller's own row.
+ *
+ * What is deliberately gone is the old third step, "any admin's key" (#1939):
+ * it read a stranger's credential, which server/CLAUDE.md forbids, and made
+ * the answer depend on who was asking — the saving admin got their own key,
+ * everybody else got the lowest-id admin's and a 403 from Google. The source
+ * is returned so a provider error can say which of the three was used.
+ */
+ resolveMapsKey(userId: number): { key: string | null; source: ApiKeySource | null } {
+ return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
+ }
- const user = this.database.get<{ maps_api_key: string | null }>(
- 'SELECT maps_api_key FROM users WHERE id = ?',
- userId,
- );
- const user_key = decrypt_api_key(user?.maps_api_key);
- if (user_key) return user_key;
- const admin = this.database.get<{ maps_api_key: string }>(
- "SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1",
- );
- return decrypt_api_key(admin?.maps_api_key) || null;
+ getMapsKey(userId: number): string | null {
+ return this.resolveMapsKey(userId).key;
}
// ── Nominatim search ───────────────────────────────────────────────────────
@@ -1417,7 +1430,7 @@ export class MapsService {
lang?: string,
locationBias?: { lat: number; lng: number; radius?: number },
): Promise<{ places: Record[]; source: string }> {
- const apiKey = this.getMapsKey(userId);
+ const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
if (!apiKey) {
const places = await this.searchNominatim(query, lang);
@@ -1441,8 +1454,7 @@ export class MapsService {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
- 'X-Goog-FieldMask':
- 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus',
+ 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify(searchBody),
});
@@ -1450,6 +1462,7 @@ export class MapsService {
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
+ logKeyFailure('searchText', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
@@ -1488,7 +1501,7 @@ export class MapsService {
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
sessionToken?: string,
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
- const apiKey = this.getMapsKey(userId);
+ const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
if (!apiKey) {
return this.autocompleteNominatim(input, lang);
@@ -1526,6 +1539,7 @@ export class MapsService {
};
if (!response.ok) {
+ logKeyFailure('autocomplete', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
err.status = response.status;
throw err;
@@ -1960,6 +1974,7 @@ export class MapsService {
lat: string,
lng: string,
lang?: string,
+ opts?: { lane?: GeoLane; timeoutMs?: number },
): Promise<{ name: string | null; address: string | null }> {
const params = new URLSearchParams({
lat,
@@ -1969,7 +1984,7 @@ export class MapsService {
zoom: '18',
'accept-language': toApiLang(lang),
});
- const response = await nominatimFetch('reverse', params);
+ const response = await nominatimFetch('reverse', params, opts);
if (!response.ok) return { name: null, address: null };
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record };
const addr = data.address || {};
diff --git a/server/src/nest/memories/immich.service.ts b/server/src/nest/memories/immich.service.ts
index b78a3d14b..fbe96529f 100644
--- a/server/src/nest/memories/immich.service.ts
+++ b/server/src/nest/memories/immich.service.ts
@@ -522,6 +522,11 @@ export class ImmichService {
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
+ // The search path has always carried these; dropping them here meant a
+ // photo picked out of an album could never be placed on a map, and the
+ // distance sort silently had nothing to sort by (#1614).
+ lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null,
+ lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets };
diff --git a/server/src/nest/memories/memories.module.ts b/server/src/nest/memories/memories.module.ts
index 2ed13a6e2..83d2c4c91 100644
--- a/server/src/nest/memories/memories.module.ts
+++ b/server/src/nest/memories/memories.module.ts
@@ -5,6 +5,7 @@ import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { UnifiedMemoriesService } from './unified-memories.service';
import { PhotoResolverService } from './photo-resolver.service';
+import { PhotoCaptureBackfillService } from './photo-capture-backfill.service';
import { ThumbnailService } from './thumbnail.service';
import { TrekPhotoCacheService } from './trek-photo-cache.service';
import { TrekPhotoCacheJob } from './trek-photo-cache.job';
@@ -54,6 +55,7 @@ import { StorageModule } from '../storage/storage.module';
SynologyService,
UnifiedMemoriesService,
PhotoResolverService,
+ PhotoCaptureBackfillService,
ThumbnailService,
TrekPhotoCacheService,
TrekPhotoCacheJob,
@@ -67,6 +69,6 @@ import { StorageModule } from '../storage/storage.module';
inject: [ImmichPhotoProvider, SynologyPhotoProvider],
},
],
- exports: [MemoriesAccessService, PhotoResolverService, ImmichService, SynologyService, PhotoProviderRegistry],
+ exports: [MemoriesAccessService, PhotoResolverService, PhotoCaptureBackfillService, ImmichService, SynologyService, PhotoProviderRegistry],
})
export class MemoriesModule {}
diff --git a/server/src/nest/memories/photo-capture-backfill.service.ts b/server/src/nest/memories/photo-capture-backfill.service.ts
new file mode 100644
index 000000000..a41908878
--- /dev/null
+++ b/server/src/nest/memories/photo-capture-backfill.service.ts
@@ -0,0 +1,105 @@
+import { Injectable } from '@nestjs/common';
+import exifr from 'exifr';
+import { PhotoResolverService } from './photo-resolver.service';
+import { StorageService } from '../storage/storage.service';
+import { TrekPhotosRepository } from '../photos/trek-photos.repository';
+
+/**
+ * Ask the provider when and where a photo was taken, and record it (#1614).
+ *
+ * The picker already sees these values, but the add call carries only the asset
+ * id — widening that contract would have meant trusting the client for something
+ * the provider can be asked for directly, and would have left the MCP path, the
+ * album sync and every already-imported photo without them.
+ *
+ * Runs detached and never throws: a provider that is slow, unreachable or simply
+ * does not know must not fail the add the user is waiting on. A photo without
+ * capture metadata is the normal case, not an error — it just will not appear on
+ * the map.
+ */
+@Injectable()
+export class PhotoCaptureBackfillService {
+ constructor(
+ private readonly resolver: PhotoResolverService,
+ private readonly photos: TrekPhotosRepository,
+ private readonly storage: StorageService,
+ ) {}
+
+ /** Fire-and-forget for a batch that was just added. */
+ schedule(trekPhotoIds: number[], userId: number): void {
+ if (!trekPhotoIds.length) return;
+ void this.run(trekPhotoIds, userId);
+ }
+
+ /** Awaitable form, so tests do not have to chase a floating promise. */
+ async run(trekPhotoIds: number[], userId: number): Promise {
+ for (const id of trekPhotoIds) {
+ try {
+ const photo = this.photos.resolve(id);
+ // A row that already knows both has nothing to gain, and a provider call
+ // per photo is the expensive part of an album import.
+ if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue;
+
+ // A local file has no provider to ask — the answer is in its own EXIF, and
+ // getPhotoInfo would only hand back what the DB row already says.
+ if (photo.provider === 'local') {
+ const meta = await this.readLocalExif(photo.file_path);
+ if (meta) this.photos.recordCaptureMetadata(id, meta);
+ continue;
+ }
+
+ const info = await this.resolver.getPhotoInfo(id, userId);
+ if (!info.success) continue;
+
+ this.photos.recordCaptureMetadata(id, {
+ takenAt: info.data.takenAt ?? null,
+ lat: info.data.lat ?? null,
+ lng: info.data.lng ?? null,
+ });
+ } catch (err) {
+ console.error(`[Photos] capture backfill failed for ${id}:`, err instanceof Error ? err.message : err);
+ }
+ }
+ }
+
+ /**
+ * EXIF of an uploaded file.
+ *
+ * Note for anyone chasing "my phone photo has no location": the client converts
+ * HEIC before upload and that conversion drops GPS, so an iPhone photo arrives
+ * here already stripped. Nothing to read is the expected outcome far more often
+ * than not — hence no logging on the empty case.
+ */
+ private async readLocalExif(
+ filePath?: string | null,
+ ): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> {
+ if (!filePath) return null;
+ // photos.file_path is uploads-relative 'journey/' by every writer;
+ // anything else reads as a miss (same rule as photo-resolver). Central key
+ // validation (storage-keys.ts) rejects a name that still carries a path,
+ // so a stored path can never climb out of the journey category.
+ if (!filePath.startsWith('journey/')) return null;
+ const name = filePath.slice('journey/'.length);
+
+ type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number };
+ let parsed: Exif | null;
+ try {
+ parsed = await this.storage.withLocalFile('journey', name, async abs =>
+ (await exifr.parse(abs, {
+ pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'],
+ })) as Exif | null,
+ );
+ } catch {
+ // A vanished object, an invalid key, not an image, a truncated upload,
+ // a video — none of it is an error here.
+ return null;
+ }
+ if (!parsed) return null;
+
+ const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null;
+ const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null;
+ const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null;
+ const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null;
+ return takenAt || lat != null ? { takenAt, lat, lng } : null;
+ }
+}
diff --git a/server/src/nest/memories/synology.service.ts b/server/src/nest/memories/synology.service.ts
index d7c61f903..312ce0d62 100644
--- a/server/src/nest/memories/synology.service.ts
+++ b/server/src/nest/memories/synology.service.ts
@@ -507,8 +507,10 @@ export class SynologyService {
while (true) {
const params: ApiCallParams = passphrase
- ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
- : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
+ // 'gps' costs nothing extra on the same call and is the only way a
+ // photo picked from an album can ever reach the map (#1614).
+ ? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail', 'gps'] }
+ : { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail', 'gps'] };
const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
if (!result.success) return result as ServiceResult;
const items = result.data.list || [];
@@ -520,6 +522,8 @@ export class SynologyService {
const assets = allItems.map(item => ({
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
+ lat: typeof item.additional?.gps?.latitude === 'number' ? item.additional.gps.latitude : null,
+ lng: typeof item.additional?.gps?.longitude === 'number' ? item.additional.gps.longitude : null,
})).filter(a => a.id);
return success({ assets, total: assets.length, hasMore: false });
diff --git a/server/src/nest/photos/trek-photos.repository.ts b/server/src/nest/photos/trek-photos.repository.ts
index 0fb1f96ae..593f8812b 100644
--- a/server/src/nest/photos/trek-photos.repository.ts
+++ b/server/src/nest/photos/trek-photos.repository.ts
@@ -89,6 +89,36 @@ export class TrekPhotosRepository {
);
}
+ /**
+ * Where and when the picture was taken (#1614).
+ *
+ * COALESCE throughout, for the same reason recordLocalThumbnail does it: the
+ * three sources disagree in how much they know. A provider search answers with
+ * coordinates, the same provider's album listing does not, and a local file
+ * only gives them up once its EXIF has been read. Whichever arrives first wins,
+ * and a later, emptier answer must not blank what is already there.
+ *
+ * A photo with neither is the normal case, not a failure.
+ */
+ recordCaptureMetadata(
+ photoId: number,
+ meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
+ ): void {
+ const { takenAt = null, lat = null, lng = null } = meta;
+ if (takenAt == null && lat == null && lng == null) return;
+ // Coordinates are stored as a pair or not at all — a lone latitude is not a
+ // place, and half a pair would put the photo on the null island.
+ const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
+ this.db.run(
+ `UPDATE trek_photos
+ SET taken_at = COALESCE(taken_at, ?),
+ lat = COALESCE(lat, ?),
+ lng = COALESCE(lng, ?)
+ WHERE id = ?`,
+ takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
+ );
+ }
+
/**
* Drop the row once no trip and no journey references it. Local photos are
* kept: their bytes are ours, and the file would outlive the row.
diff --git a/server/src/nest/places/places.helpers.ts b/server/src/nest/places/places.helpers.ts
index 47ed4bf98..532a4616d 100644
--- a/server/src/nest/places/places.helpers.ts
+++ b/server/src/nest/places/places.helpers.ts
@@ -254,6 +254,12 @@ export const SEARCH_BIAS_RADIUS_METERS = 2000;
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
export const ENRICH_CONCURRENCY = 3;
+// The free address backfill is one Nominatim request per place on the throttled
+// background lane (~1/s), so a normal list costs seconds. Past this it stops being
+// a backfill and starts being bulk geocoding, which Nominatim's usage policy asks
+// people not to do — so it stops rather than queueing for an hour.
+export const ADDRESS_BACKFILL_MAX_PLACES = 250;
+
/**
* Pick the search result that is the same place as the import: it must be a
diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts
index e1aa65482..fea5d1e94 100644
--- a/server/src/nest/places/places.service.ts
+++ b/server/src/nest/places/places.service.ts
@@ -30,6 +30,7 @@ import { StorageService } from '../storage/storage.service';
import {
COORD_DEDUP_TOLERANCE,
ENRICH_CONCURRENCY,
+ ADDRESS_BACKFILL_MAX_PLACES,
escapeLikePattern,
MAX_LIST_RESPONSE_BYTES,
googleMapsFeatureIdFromItem,
@@ -1007,8 +1008,8 @@ export class PlacesService {
}
});
- if (opts?.enrich && opts.userId && created.length) {
- void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
+ if (created.length) {
+ void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
}
return { places: created, listName, skipped };
@@ -1150,8 +1151,8 @@ export class PlacesService {
}
});
- if (opts?.enrich && opts.userId && created.length) {
- void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
+ if (created.length) {
+ void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
}
return { places: created, listName, skipped };
@@ -1250,6 +1251,63 @@ export class PlacesService {
}
}
+ /**
+ * Everything a just-imported list gets in the background, in one place so the
+ * Google and Naver call sites cannot drift apart.
+ *
+ * The Google pass stays exactly as opt-in and key-gated as before. The address
+ * backfill behind it needs neither — it is the same free Nominatim reverse
+ * lookup a single pasted link has always had, which is what made a list import
+ * come out thinner than the same place added one at a time (#1954).
+ */
+ private async enrichImportedList(tripId: string, places: EnrichablePlace[], opts?: ListImportOptions): Promise {
+ if (opts?.enrich && opts.userId) {
+ await this.enrichImportedPlaces(tripId, opts.userId, places, opts.lang);
+ }
+ await this.backfillMissingAddresses(tripId, places, opts?.lang);
+ }
+
+ /**
+ * Fill in the address of imported places that have none, from Nominatim.
+ *
+ * Runs on the background lane so it never queues in front of a user's own
+ * search, writes through COALESCE so it can only fill an empty column — the
+ * Google pass above may already have written a better one — and pushes each
+ * row over the websocket so the sidebar fills in without a reload. Never
+ * throws: one bad lookup must not take down a detached task.
+ */
+ async backfillMissingAddresses(tripId: string, places: EnrichablePlace[], lang?: string): Promise {
+ try {
+ const pending = places.filter(p => !p.address && p.lat != null && p.lng != null);
+ if (!pending.length) return;
+ if (pending.length > ADDRESS_BACKFILL_MAX_PLACES) {
+ console.warn(`[Places] address backfill skipped for trip ${tripId}: ${pending.length} places exceeds the ${ADDRESS_BACKFILL_MAX_PLACES} cap`);
+ return;
+ }
+ // Serial on purpose: the background lane throttles to roughly one request a
+ // second anyway, so concurrency would only build a queue.
+ for (const place of pending) {
+ try {
+ const { address } = await this.maps.reverseGeocode(String(place.lat), String(place.lng), lang, {
+ lane: 'background',
+ timeoutMs: 10000,
+ });
+ if (!address) continue;
+ this.dbs.run(
+ 'UPDATE places SET address = COALESCE(address, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
+ address, place.id, tripId,
+ );
+ const updated = this.dbs.getPlaceWithTags(place.id);
+ if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined);
+ } catch (err) {
+ console.error(`[Places] address backfill failed for place ${place.id}:`, err instanceof Error ? err.message : err);
+ }
+ }
+ } catch (err) {
+ console.error('[Places] address backfill pass failed:', err instanceof Error ? err.message : err);
+ }
+ }
+
// -------------------------------------------------------------------------
// Search place image (Unsplash)
// -------------------------------------------------------------------------
diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts
index f8b3926ba..869528099 100644
--- a/server/src/nest/reservations/reservations.mcp.ts
+++ b/server/src/nest/reservations/reservations.mcp.ts
@@ -90,6 +90,7 @@ function parseId(value: string | string[]): number | null {
const legsSchema = transportLegsInputSchema.optional().describe(
'Per-segment detail of a stopover flight or train: one entry per segment, in route order, exactly ONE FEWER than endpoints[]. '
+ 'Each leg carries its own departure and arrival day + local time (dep_day_id/dep_time, arr_day_id/arr_time) plus airline/flight_number (flights) or train_number/platform (trains). '
+ + 'A leg may also carry its own confirmation_number when that segment was issued a separate booking reference; leave it out and the booking-level confirmation_number covers the segment. '
+ "Required for a booking with stopovers: a stop's endpoint stores only the onward departure, so without legs that one time is shown as both the arrival and the departure. "
+ 'Omit it for a direct booking. Blank endpoint times/dates, day_id/end_day_id and reservation_time are derived from the legs; a value that contradicts them is rejected.'
);
@@ -207,7 +208,7 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } {
from: leg.from ?? depEp.code ?? depEp.name,
to: leg.to ?? arrEp.code ?? arrEp.name,
};
- for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat'] as const) {
+ for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat', 'confirmation_number'] as const) {
const value = leg[key];
if (value) entry[key] = value;
}
@@ -570,7 +571,7 @@ export class ReservationsMcp {
@Tool({
name: 'create_transport',
- description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
+ description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -678,7 +679,7 @@ export class ReservationsMcp {
@Tool({
name: 'update_transport',
- description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. Use status "confirmed" to confirm.',
+ description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. A per-segment booking reference lives on the leg (legs[].confirmation_number); the top-level one stays the booking reference. Use status "confirmed" to confirm.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
diff --git a/server/src/nest/settings/instance-api-keys.ts b/server/src/nest/settings/instance-api-keys.ts
new file mode 100644
index 000000000..a5c4d4bd9
--- /dev/null
+++ b/server/src/nest/settings/instance-api-keys.ts
@@ -0,0 +1,89 @@
+import { DatabaseService } from '../database/database.service';
+import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
+
+/**
+ * The third-party keys that belong to the instance rather than to a person.
+ *
+ * Google Places and Unsplash bill the install, not the account that pasted the
+ * key in, so the value is instance configuration — the same argument #1772 made
+ * for the LLM endpoint (see llm-parse/llm-config.ts: instance-wide, admin-set,
+ * wins; per-user as the fallback). Both keys used to be resolved out of the
+ * `users` columns alone, which meant "the key of the admin with the lowest id"
+ * for everyone else and produced #1939: the admin searched with the key they
+ * had just saved, every other member searched with a stranger's and got a 403
+ * from Google.
+ *
+ * Stored as an encrypted `app_settings` row under the name below, reusing
+ * apiKeyCrypto, so the format matches what the users columns already hold and
+ * a legacy plaintext value still reads back.
+ */
+export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key';
+
+/** Instance names whose per-user column is still honoured as a last resort. */
+export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = ['maps_api_key', 'unsplash_api_key'];
+
+/**
+ * Where a resolved key came from. Logged beside a provider error so "works for
+ * the admin, 403 for everyone else" is one line in the log rather than a
+ * guessing game; the key itself is never logged.
+ */
+export type ApiKeySource = 'operator-env' | 'instance' | 'user-row';
+
+// Full statements rather than an interpolated column: the name doubles as the
+// users column AND the app_settings key, and identifiers only ever come from a
+// literal allow-list.
+const USER_ROW_SQL: Record = {
+ maps_api_key: 'SELECT maps_api_key FROM users WHERE id = ?',
+ unsplash_api_key: 'SELECT unsplash_api_key FROM users WHERE id = ?',
+};
+
+/** The instance-wide value in cleartext, or null when unset/cleared. */
+export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): string | null {
+ const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', name);
+ if (!row?.value) return null;
+ return decrypt_api_key(row.value) || null;
+}
+
+/**
+ * Store the instance-wide value, encrypted. A blank value stores '' rather than
+ * deleting the row: an admin who clears the field means "this instance has no
+ * key", and a missing row would let the resolver fall through to whatever old
+ * value still sits in their own users column.
+ */
+export function writeInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName, value: unknown): void {
+ db.run(
+ `INSERT INTO app_settings (key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
+ name, maybe_encrypt_api_key(value) ?? ''
+ );
+}
+
+/**
+ * Resolve a key for one request: operator env → instance-wide → the caller's
+ * own row.
+ *
+ * The caller's own row is last, not second, and no other user's row is ever
+ * read — falling back to a stranger's key is what server/CLAUDE.md forbids and
+ * what #1939 reported. It stays as a fallback because `PUT /me/api-keys` has
+ * always accepted a personal key and dropping it would break the installs where
+ * each member pays for their own.
+ *
+ * `userId` 0 means "nobody is asking" (the unauthenticated app-config read):
+ * there is no personal key to find, so the chain ends at the instance.
+ */
+export function resolveApiKey(
+ db: DatabaseService,
+ name: InstanceApiKeyName,
+ userId: number,
+ operatorKey: string | undefined,
+): { key: string | null; source: ApiKeySource | null } {
+ if (operatorKey) return { key: operatorKey, source: 'operator-env' };
+
+ const instance = readInstanceApiKey(db, name);
+ if (instance) return { key: instance, source: 'instance' };
+ if (!userId) return { key: null, source: null };
+
+ const row = db.get>(USER_ROW_SQL[name], userId);
+ const own = decrypt_api_key(row?.[name]) || null;
+ return own ? { key: own, source: 'user-row' } : { key: null, source: null };
+}
diff --git a/server/src/nest/unsplash/unsplash.service.ts b/server/src/nest/unsplash/unsplash.service.ts
index 3cfd7ccf1..f3426c1eb 100644
--- a/server/src/nest/unsplash/unsplash.service.ts
+++ b/server/src/nest/unsplash/unsplash.service.ts
@@ -2,7 +2,7 @@ import { Readable } from 'node:stream';
import { v4 as uuidv4 } from 'uuid';
import { Injectable } from '@nestjs/common';
import { safeFetch } from '../../utils/ssrfGuard';
-import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
+import { resolveApiKey } from '../settings/instance-api-keys';
import { DatabaseService } from '../database/database.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { StorageService } from '../storage/storage.service';
@@ -42,7 +42,7 @@ const COVER_EXT_BY_TYPE: Record = {
* Unsplash search and cover download.
*
* Its own module rather than a method on trips or places: both call it, and the
- * key resolution reads the env AND the users table, so it needs the injected
+ * key resolution reads the env AND the database, so it needs the injected
* connection either way.
*/
@Injectable()
@@ -56,18 +56,14 @@ export class UnsplashService {
/**
* Resolve an Unsplash access key for a request, in precedence order:
* the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the
- * SMTP/OIDC env-over-DB convention) → the caller's own stored key → any
- * admin's stored key. Mirrors `getMapsKey`. Returns null when none is set,
- * in which case the search falls back to the unauthenticated endpoint.
+ * SMTP/OIDC env-over-DB convention) → the instance-wide value the admin panel
+ * saves → the caller's own stored key. Mirrors `resolveMapsKey`, including what
+ * it dropped: "any admin's stored key" read a stranger's credential and made
+ * the answer depend on who asked (#1939). Returns null when none is set, in
+ * which case the search falls back to the unauthenticated endpoint.
*/
getUnsplashKey(userId: number): string | null {
- const env_key = this.env.env().integrations.unsplashAccessKey;
- if (env_key) return env_key;
- const user = this.db.get<{ unsplash_api_key: string | null }>('SELECT unsplash_api_key FROM users WHERE id = ?', userId);
- const user_key = decrypt_api_key(user?.unsplash_api_key);
- if (user_key) return user_key;
- const admin = this.db.get<{ unsplash_api_key: string }>("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1");
- return decrypt_api_key(admin?.unsplash_api_key) || null;
+ return resolveApiKey(this.db, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey).key;
}
async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
diff --git a/server/src/nest/weather/weather.controller.ts b/server/src/nest/weather/weather.controller.ts
index 41f9ca6ec..fe07451f1 100644
--- a/server/src/nest/weather/weather.controller.ts
+++ b/server/src/nest/weather/weather.controller.ts
@@ -27,12 +27,14 @@ export class WeatherController {
@Query('lng') lng?: string,
@Query('date') date?: string,
@Query('lang') lang?: string,
+ /** HH:MM — lets a past date answer for that hour rather than the day (#1614). */
+ @Query('time') time?: string,
): Promise {
if (!lat || !lng) {
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
}
try {
- return await this.weather.get(lat, lng, date, lang ?? 'de');
+ return await this.weather.get(lat, lng, date, lang ?? 'de', time);
} catch (err: unknown) {
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
}
diff --git a/server/src/nest/weather/weather.impl.ts b/server/src/nest/weather/weather.impl.ts
index 76523b44c..6499dd3a9 100644
--- a/server/src/nest/weather/weather.impl.ts
+++ b/server/src/nest/weather/weather.impl.ts
@@ -1,4 +1,24 @@
+// Open-Meteo sits on the request path as a third party: without a deadline a hung
+// connection holds the caller until the socket gives up on its own. Every other
+// outbound client in the codebase carries one.
+const WEATHER_TIMEOUT_MS = 8000;
+
+/**
+ * Which slot of Open-Meteo's hourly series a HH:MM belongs to.
+ *
+ * The archive answers with one entry per hour of the requested day in the local
+ * timezone, so the hour is the index. Anything unparseable means "no time given",
+ * which falls back to the daily figures rather than guessing midnight.
+ */
+function hourIndexFromTime(time?: string): number | null {
+ if (!time) return null;
+ const m = /^(\d{1,2}):(\d{2})/.exec(time.trim());
+ if (!m) return null;
+ const hour = Number(m[1]);
+ return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null;
+}
+
// ── Interfaces ──────────────────────────────────────────────────────────
export interface WeatherResult {
@@ -177,8 +197,9 @@ async function _getWeatherImpl(
lng: string,
date: string | undefined,
lang: string,
+ time?: string,
): Promise {
- const ck = cacheKey(lat, lng, date);
+ const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
if (date) {
const cached = getCached(ck);
@@ -191,7 +212,7 @@ async function _getWeatherImpl(
// Forecast range (-1 .. +16 days)
if (diffDays >= -1 && diffDays <= 16) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
- const response = await fetch(url);
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -222,14 +243,36 @@ async function _getWeatherImpl(
// Past date: use archive API for the actual date
if (diffDays < -1) {
const dateStr = targetDate.toISOString().slice(0, 10);
- const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum&timezone=auto`;
- const response = await fetch(url);
+ // With a time in hand, ask for the hourly series too: a journal entry made at
+ // 14:30 wants the weather of that hour, not the day's high and low averaged
+ // together, which is what a rainy morning and a bright afternoon collapse to.
+ const hourParam = hourIndexFromTime(time) != null
+ ? '&hourly=temperature_2m,weathercode'
+ : '';
+ const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum${hourParam}&timezone=auto`;
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Archive API error');
}
+ const hourIdx = hourIndexFromTime(time);
+ const hourly = (data as { hourly?: { temperature_2m?: (number | null)[]; weathercode?: (number | null)[] } }).hourly;
+ if (hourIdx != null && hourly?.temperature_2m?.[hourIdx] != null) {
+ const temp = hourly.temperature_2m[hourIdx]!;
+ const code = hourly.weathercode?.[hourIdx] ?? undefined;
+ const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
+ const result: WeatherResult = {
+ temp: Math.round(temp),
+ main: WMO_MAP[code!] || estimateCondition(temp, 0),
+ description: descriptions[code!] || '',
+ type: 'forecast',
+ };
+ setCache(ck, result, TTL_CLIMATE_MS);
+ return result;
+ }
+
const daily = data.daily;
if (daily && daily.time && daily.time.length > 0 && daily.temperature_2m_max[0] != null) {
const code = daily.weathercode?.[0];
@@ -264,7 +307,7 @@ async function _getWeatherImpl(
const endStr = endDate.toISOString().slice(0, 10);
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
- const response = await fetch(url);
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -317,7 +360,7 @@ async function _getWeatherImpl(
if (cached) return cached;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,weathercode&timezone=auto`;
- const response = await fetch(url);
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -343,15 +386,17 @@ export async function getWeather(
lng: string,
date: string | undefined,
lang: string,
+ /** HH:MM of the moment being asked about. Only the archive path can use it. */
+ time?: string,
): Promise {
- const ck = cacheKey(lat, lng, date);
+ const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
const cached = getCached(ck);
if (cached) return cached;
const inFlightKey = `${ck}:${lang}`;
const existing = inFlight.get(inFlightKey);
if (existing) return existing;
- const promise = _getWeatherImpl(lat, lng, date, lang);
+ const promise = _getWeatherImpl(lat, lng, date, lang, time);
inFlight.set(inFlightKey, promise);
try { return await promise; } finally { inFlight.delete(inFlightKey); }
}
@@ -388,7 +433,7 @@ async function _getDetailedWeatherImpl(
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
+ `&timezone=auto`;
- const response = await fetch(url);
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -451,7 +496,7 @@ async function _getDetailedWeatherImpl(
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
- const response = await fetch(url);
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
diff --git a/server/src/nest/weather/weather.service.ts b/server/src/nest/weather/weather.service.ts
index 22f1865cc..13c19d568 100644
--- a/server/src/nest/weather/weather.service.ts
+++ b/server/src/nest/weather/weather.service.ts
@@ -25,8 +25,8 @@ export class WeatherService implements OnModuleInit, OnModuleDestroy {
stopCacheCleanup();
}
- get(lat: string, lng: string, date: string | undefined, lang: string): Promise {
- return getWeather(lat, lng, date, lang) as Promise;
+ get(lat: string, lng: string, date: string | undefined, lang: string, time?: string): Promise {
+ return getWeather(lat, lng, date, lang, time) as Promise;
}
getDetailed(lat: string, lng: string, date: string, lang: string): Promise {
diff --git a/server/src/types.ts b/server/src/types.ts
index 4187e4622..7990c3717 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -398,6 +398,11 @@ export interface TrekPhoto {
media_type?: string | null;
/** Optional video duration in milliseconds. */
duration_ms?: number | null;
+ /** When the picture was taken, as the provider or its EXIF reported it (#1614). */
+ taken_at?: string | null;
+ /** Capture coordinates. Stored as a pair or not at all. */
+ lat?: number | null;
+ lng?: number | null;
created_at: string;
}
diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts
index baa089a4d..bf702a102 100644
--- a/server/tests/e2e/atlas.e2e.test.ts
+++ b/server/tests/e2e/atlas.e2e.test.ts
@@ -184,6 +184,39 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
expect(jp.status).toBe(200);
expect(jp.body.status).toBe('planned');
});
+ // #1535: the same journey booked as two flights, which is what an AirTrail import
+ // whose legs never chained leaves behind. The hub is a legitimate 'to'/'from' pair, so
+ // only the ground time between them says it was a plane change. Own user again.
+ it('200 stats leaves out a layover country whose two legs are separate bookings', async () => {
+ const flyerId = createUser(db as never, { username: 'atlas-flyer', email: 'atlas-flyer@test.example' }).user.id;
+ const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
+ const trip = createTrip(db as never, flyerId, {
+ title: 'New York via Helsinki',
+ start_date: iso(-20),
+ end_date: iso(-10),
+ });
+ const day = iso(-15);
+ const insertFlight = db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, ?, 'flight')");
+ const insertEndpoint = db.prepare(
+ 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ );
+ const inbound = insertFlight.run(trip.id, 'BRU-HEL').lastInsertRowid;
+ insertEndpoint.run(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00');
+ insertEndpoint.run(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30');
+ const onward = insertFlight.run(trip.id, 'HEL-JFK').lastInsertRowid;
+ insertEndpoint.run(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00');
+ insertEndpoint.run(onward, 'to', 1, 'New York', 40.6413, -73.7781, 'JFK', day, '15:00');
+
+ const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(flyerId));
+ expect(res.status).toBe(200);
+
+ const codes = (res.body.countries as { code: string }[]).map((c) => c.code);
+ expect(codes).toContain('BE');
+ expect(codes).toContain('US');
+ expect(codes).not.toContain('FI');
+ expect(res.body.stats.totalCountries).toBe(2);
+ });
+
// ── /locate (#1115) ────────────────────────────────────────────────────────
describe('GET locate', () => {
it('resolves a coordinate to the country and the region the map can highlight', async () => {
diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts
index c1bd09cdf..7ef0f1b49 100644
--- a/server/tests/e2e/auth.e2e.test.ts
+++ b/server/tests/e2e/auth.e2e.test.ts
@@ -45,6 +45,7 @@ import { MailerService } from '../../src/nest/notifications/mailer/mailer.servic
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser } from '../helpers/factories';
+import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
import { resetRateLimits } from '../helpers/test-db';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { AuthService } from '../../src/nest/auth/auth.service';
@@ -251,6 +252,80 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
expect(res.body.error).toMatch(/password/i);
});
+ it('PUT /me/api-keys stores the key instance-wide and audits it once (#1939)', async () => {
+ const admin = createUser(db as never, { username: 'keys-admin', email: 'keys-admin@example.test', role: 'admin' });
+ const before = auditRows('settings.api_keys_update');
+
+ const res = await request(server)
+ .put('/api/auth/me/api-keys')
+ .set('Cookie', sessionCookie(admin.user.id))
+ .send({ maps_api_key: 'e2e-google-key' });
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ // Masked in the response and never accompanied by the audit bookkeeping.
+ expect(res.body.user.maps_api_key).not.toBe('e2e-google-key');
+ expect(res.body).not.toHaveProperty('changedKeys');
+
+ // What every member's search will now resolve to, encrypted at rest.
+ const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'maps_api_key'").get() as { value: string };
+ expect(stored.value).toMatch(/^enc:v1:/);
+ // And the admin panel reads back what the search uses.
+ const settings = await request(server).get('/api/auth/me/settings').set('Cookie', sessionCookie(admin.user.id));
+ expect(settings.body.settings.maps_api_key).toBe('e2e-google-key');
+
+ expect(auditRows('settings.api_keys_update')).toBe(before + 1);
+ const row = db
+ .prepare("SELECT resource, details FROM audit_log WHERE action = 'settings.api_keys_update' ORDER BY id DESC LIMIT 1")
+ .get() as { resource: string; details: string };
+ expect(row.resource).toBe('api_keys');
+ expect(row.details).toContain('maps_api_key');
+ expect(row.details).not.toContain('e2e-google-key');
+
+ // Saving the same value again is not a change, so the log does not grow.
+ await request(server)
+ .put('/api/auth/me/api-keys')
+ .set('Cookie', sessionCookie(admin.user.id))
+ .send({ maps_api_key: 'e2e-google-key' });
+ expect(auditRows('settings.api_keys_update')).toBe(before + 1);
+ }, 10000);
+
+ it('GET /app-config answers has_maps_key from the instance row, never from an admin column (#1939)', async () => {
+ const member = createUser(db as never, { username: 'keys-member', email: 'keys-member@example.test' });
+ const admin = createUser(db as never, { username: 'keys-cfg-admin', email: 'keys-cfg-admin@example.test', role: 'admin' });
+ // Seeded here instead of riding on the save above, so running this case on
+ // its own asserts the same thing.
+ const setInstanceKey = (value: string | null) => {
+ if (value === null) {
+ db.prepare("DELETE FROM app_settings WHERE key = 'maps_api_key'").run();
+ return;
+ }
+ db.prepare(
+ `INSERT INTO app_settings (key, value) VALUES ('maps_api_key', ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
+ ).run(encrypt_api_key(value));
+ };
+ const hasMapsKey = async (userId: number) => {
+ const res = await request(server).get('/api/auth/app-config').set('Cookie', sessionCookie(userId));
+ expect(res.status).toBe(200);
+ return res.body.has_maps_key;
+ };
+
+ setInstanceKey('instance-configured-key');
+ db.prepare('UPDATE users SET maps_api_key = NULL WHERE id = ?').run(admin.user.id);
+ // Instance key, no column anywhere: the member searches with it, so the
+ // client is told the feature is there.
+ expect(await hasMapsKey(member.user.id)).toBe(true);
+
+ // The reported half of #1939: the key exists only in one admin's column.
+ // It is not the member's to spend, so they are told there is none instead
+ // of being offered a Google search that answers 403.
+ setInstanceKey(null);
+ db.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run(encrypt_api_key('admins-own-key'), admin.user.id);
+ expect(await hasMapsKey(member.user.id)).toBe(false);
+ // That same column still counts for the admin themselves.
+ expect(await hasMapsKey(admin.user.id)).toBe(true);
+ });
+
it('POST /logout clears the session cookie', async () => {
const res = await request(server).post('/api/auth/logout');
expect(res.status).toBe(200);
diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts
index a2f3704f8..5ef3d0346 100644
--- a/server/tests/helpers/factories.ts
+++ b/server/tests/helpers/factories.ts
@@ -99,7 +99,11 @@ export function createTrip(
const end = new Date(overrides.end_date);
const tripId = result.lastInsertRowid as number;
let dayNumber = 1;
- for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
+ // Step in UTC. A date-only ISO string parses as UTC midnight, but setDate()
+ // advances local wall-clock time — 23h or 25h across a DST transition — so the
+ // instant drifts off midnight and toISOString() then reports the wrong day:
+ // one is dropped in autumn, one repeated in spring. CI runs UTC and never sees it.
+ for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const dateStr = d.toISOString().slice(0, 10);
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, dayNumber++, dateStr);
}
diff --git a/server/tests/integration/leg-mode-incoming.test.ts b/server/tests/integration/leg-mode-incoming.test.ts
index 4cae38bc2..af743c0ed 100644
--- a/server/tests/integration/leg-mode-incoming.test.ts
+++ b/server/tests/integration/leg-mode-incoming.test.ts
@@ -43,10 +43,17 @@ describe('incoming_leg_transport_mode migration', () => {
//
// >>> Appending a migration? Re-point the undo below at whatever yours
// >>> does. That is the whole maintenance cost of this guard.
- // Trailing migrations today (a dev/feat-storage-driver merge landed two
- // in one release window, so the rewind spans both):
- // version-2 — reservation_day_positions cross-trip cleanup: undone by
+ // Trailing migrations today (a dev/feat-storage-driver merge landed five
+ // in one release window, so the rewind spans all of them):
+ // version-5 — reservation_day_positions cross-trip cleanup: undone by
// re-inserting a mismatched reservation/day pair.
+ // version-4 — #1939 instance API key promotion: nothing to undo — the
+ // seeded users are not admins and hold no keys, so the
+ // replay is a no-op by its own guards.
+ // version-3 — trek_photos capture metadata (#1614): column-guarded, so
+ // the replay is a no-op; the from-scratch case above covers it.
+ // version-2 — journey_share_tokens.newest_first: undone by dropping the
+ // column and letting the replay put it back.
// version-1 — storage slice-2 trip_files 'files/' prefix strip: undone
// by re-inserting a prefixed row.
const upgraded = new Database(':memory:');
@@ -68,7 +75,15 @@ describe('incoming_leg_transport_mode migration', () => {
insertPosition.run(reservationOnA, dayOnA, 2); // a legitimate one it must leave alone
upgraded.prepare("INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, 'files/aaa.pdf', 'a.pdf')").run(tripA);
- upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 2);
+ const hasNewestFirst = () =>
+ (upgraded.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as { name: string }[])
+ .some(c => c.name === 'newest_first');
+
+ expect(hasNewestFirst()).toBe(true);
+ upgraded.exec('ALTER TABLE journey_share_tokens DROP COLUMN newest_first');
+ expect(hasNewestFirst()).toBe(false);
+
+ upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 5);
runMigrations(upgraded);
@@ -76,6 +91,7 @@ describe('incoming_leg_transport_mode migration', () => {
expect(positions).toEqual([{ day_id: Number(dayOnA) }]);
const files = upgraded.prepare('SELECT filename FROM trip_files').all() as { filename: string }[];
expect(files.map(r => r.filename)).toEqual(['aaa.pdf']);
+ expect(hasNewestFirst()).toBe(true);
expect(upgraded.prepare('SELECT version FROM schema_version').get()).toEqual({ version });
upgraded.close();
});
diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts
index b57184941..87160a9f4 100644
--- a/server/tests/integration/memories-immich.test.ts
+++ b/server/tests/integration/memories-immich.test.ts
@@ -56,7 +56,7 @@ const { testDb, dbMock, immichState } = vi.hoisted(() => {
* the legacy one. Both must be filtered out of the picker (#1474).
*/
const DEFAULT_ALBUM_ASSETS = [
- { id: 'asset-sync-1', type: 'IMAGE', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France' } },
+ { id: 'asset-sync-1', type: 'IMAGE', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France', latitude: 48.8584, longitude: 2.2945 } },
{ id: 'asset-sync-2', type: 'VIDEO', fileCreatedAt: '2024-06-02T10:00:00.000Z', exifInfo: { city: 'Lyon', country: 'France' } },
{ id: 'asset-hidden', type: 'VIDEO', fileCreatedAt: '2024-06-03T10:00:00.000Z', visibility: 'hidden' },
{ id: 'asset-legacy-hidden', type: 'VIDEO', fileCreatedAt: '2024-06-04T10:00:00.000Z', isVisible: false },
@@ -590,6 +590,23 @@ describe('Immich album photos', () => {
expect(res.body.assets[1].mediaType).toBe('video');
});
+ // #1614 — the album mapping used to drop lat/lng even though the search path
+ // kept them, so a photo picked from an album could never reach a map.
+ it('IMMICH-064b — album photos carry their capture coordinates, and tolerate their absence', async () => {
+ const { user } = createUser(testDb);
+ setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
+
+ const res = await request(app)
+ .get(`${IMMICH}/albums/album-uuid-1/photos`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.body.assets[0]).toMatchObject({ lat: 48.8584, lng: 2.2945 });
+ // The second fixture asset has no coordinates; it must come back null, not undefined
+ // or a half pair.
+ expect(res.body.assets[1].lat).toBeNull();
+ expect(res.body.assets[1].lng).toBeNull();
+ });
+
it('IMMICH-065 — album photos are fetched via search/metadata with albumIds and withExif', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
diff --git a/server/tests/integration/security.test.ts b/server/tests/integration/security.test.ts
index 7745e3c16..6241c53fb 100644
--- a/server/tests/integration/security.test.ts
+++ b/server/tests/integration/security.test.ts
@@ -136,6 +136,35 @@ describe('API key encryption', () => {
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
});
+ it('SEC-008 — saving keys audits the names and never the values (#1939)', async () => {
+ const { user } = createUser(testDb);
+
+ const save = () =>
+ request(app)
+ .put('/api/auth/me/api-keys')
+ .set('Cookie', authCookie(user.id))
+ .send({ openweather_api_key: 'test-api-key-12345' });
+
+ const first = await save();
+ expect(first.status).toBe(200);
+ // changedKeys is internal: the client body is what it always was.
+ expect(first.body).not.toHaveProperty('changedKeys');
+
+ const rows = () =>
+ testDb
+ .prepare("SELECT details FROM audit_log WHERE action = 'settings.api_keys_update'")
+ .all() as { details: string | null }[];
+ expect(rows()).toHaveLength(1);
+ expect(rows()[0].details).toContain('openweather_api_key');
+ expect(rows()[0].details).not.toContain('test-api-key-12345');
+
+ // The same value again writes no second row. This is the real test of the
+ // cleartext comparison: encryption uses a random IV, so the stored blob
+ // differs on every save even when the key does not.
+ await save();
+ expect(rows()).toHaveLength(1);
+ });
+
it('SEC-008 — GET /api/auth/me does not return plaintext API key', async () => {
const { user } = createUser(testDb);
await request(app)
diff --git a/server/tests/unit/db/instance-api-key-backfill.test.ts b/server/tests/unit/db/instance-api-key-backfill.test.ts
new file mode 100644
index 000000000..a8be1280a
--- /dev/null
+++ b/server/tests/unit/db/instance-api-key-backfill.test.ts
@@ -0,0 +1,141 @@
+/**
+ * Unit test for the instance API-key backfill migration (#1939).
+ *
+ * The Places/Unsplash credential moved from "whichever admin row the resolver
+ * happened to find" to an instance-wide app_settings row. An install that
+ * upgrades must keep searching with the key it already had, so the migration
+ * copies the value the old resolver would have handed out — the lowest-id admin
+ * who has one — and leaves the users columns alone. It only does so where that
+ * value was the whole install's key: as soon as a second row holds one, the
+ * promotion is skipped, because the instance row outranks everybody's own
+ * column and nobody may be moved onto a stranger's key by an upgrade.
+ *
+ * Seeded before runMigrations rather than by rewinding schema_version
+ * afterwards: createTables already brings users.role and both key columns, so
+ * the backfill fires on its normal pass and finds the rows. That keeps this
+ * file independent of where the entry sits in the append-only array, so
+ * appending the next migration does not drag it along. The one guard that does
+ * have to track the tail is tests/integration/leg-mode-incoming.test.ts, which
+ * says so in place.
+ */
+import { describe, it, expect } from 'vitest';
+import Database from 'better-sqlite3';
+import { createTables } from '../../../src/db/schema';
+import { runMigrations } from '../../../src/db/migrations';
+
+function freshDb() {
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ createTables(db);
+ return db;
+}
+
+function seedUser(
+ db: Database.Database,
+ id: number,
+ role: 'admin' | 'user',
+ keys: { maps?: string; unsplash?: string } = {},
+) {
+ db.prepare(
+ `INSERT INTO users (id, username, email, password_hash, role, maps_api_key, unsplash_api_key)
+ VALUES (?, ?, ?, 'x', ?, ?, ?)`
+ ).run(id, `u${id}`, `u${id}@test.local`, role, keys.maps ?? null, keys.unsplash ?? null);
+}
+
+const setting = (db: Database.Database, key: string) =>
+ (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
+
+describe('instance API-key backfill migration', () => {
+ it('KEYFILL-001: copies the one key the install was searching with and keeps the column', () => {
+ const db = freshDb();
+ seedUser(db, 1, 'admin', { maps: 'the-admin-google', unsplash: 'the-admin-unsplash' });
+ seedUser(db, 2, 'user');
+
+ runMigrations(db);
+
+ expect(setting(db, 'maps_api_key')).toBe('the-admin-google');
+ expect(setting(db, 'unsplash_api_key')).toBe('the-admin-unsplash');
+ // Nothing is taken away: the columns are still the per-user fallback.
+ const row = db.prepare('SELECT maps_api_key FROM users WHERE id = 1').get() as { maps_api_key: string };
+ expect(row.maps_api_key).toBe('the-admin-google');
+ db.close();
+ });
+
+ it('KEYFILL-002: skips an admin whose column is empty and takes the next one', () => {
+ const db = freshDb();
+ seedUser(db, 1, 'admin', { maps: '' });
+ seedUser(db, 2, 'admin', { maps: 'the-only-real-key' });
+
+ runMigrations(db);
+
+ expect(setting(db, 'maps_api_key')).toBe('the-only-real-key');
+ db.close();
+ });
+
+ it('KEYFILL-003: never overwrites a value the admin has already saved instance-wide', () => {
+ const db = freshDb();
+ seedUser(db, 1, 'admin', { maps: 'old-column-key' });
+ db.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'already-instance-wide')").run();
+
+ runMigrations(db);
+
+ expect(setting(db, 'maps_api_key')).toBe('already-instance-wide');
+ db.close();
+ });
+
+ it('KEYFILL-004: ignores a non-admin key and writes no row at all', () => {
+ const db = freshDb();
+ seedUser(db, 1, 'user', { maps: 'members-own-key' });
+
+ runMigrations(db);
+
+ // A member's key stays theirs — the resolver still finds it for them, and
+ // promoting it would hand their billing to the whole instance.
+ expect(setting(db, 'maps_api_key')).toBeUndefined();
+ db.close();
+ });
+
+ it('KEYFILL-005: writes nothing on an install that never had a key', () => {
+ const db = freshDb();
+ seedUser(db, 1, 'admin');
+
+ runMigrations(db);
+
+ expect(setting(db, 'maps_api_key')).toBeUndefined();
+ expect(setting(db, 'unsplash_api_key')).toBeUndefined();
+ db.close();
+ });
+
+ it('KEYFILL-006: leaves a column alone once a member holds a key of their own', () => {
+ const db = freshDb();
+ seedUser(db, 1, 'admin', { maps: 'admins-own-google', unsplash: 'admins-own-unsplash' });
+ seedUser(db, 7, 'user', { maps: 'members-own-google' });
+
+ runMigrations(db);
+
+ // Member 7 pays for their own Google key. Promoting the admin's would put
+ // every one of their searches on his key and his bill, because the instance
+ // row is resolved before their own column.
+ expect(setting(db, 'maps_api_key')).toBeUndefined();
+ // Decided per column: nobody else has an Unsplash key, so that one really
+ // was the whole install's and stays it.
+ expect(setting(db, 'unsplash_api_key')).toBe('admins-own-unsplash');
+ const row = db.prepare('SELECT maps_api_key FROM users WHERE id = 7').get() as { maps_api_key: string };
+ expect(row.maps_api_key).toBe('members-own-google');
+ db.close();
+ });
+
+ it('KEYFILL-007: two admins with their own keys both keep them', () => {
+ const db = freshDb();
+ seedUser(db, 1, 'admin', { maps: 'first-admin-google' });
+ seedUser(db, 2, 'admin', { maps: 'second-admin-google' });
+
+ runMigrations(db);
+
+ expect(setting(db, 'maps_api_key')).toBeUndefined();
+ const rows = db.prepare('SELECT maps_api_key FROM users ORDER BY id').all() as { maps_api_key: string }[];
+ expect(rows.map((r) => r.maps_api_key)).toEqual(['first-admin-google', 'second-admin-google']);
+ db.close();
+ });
+});
diff --git a/server/tests/unit/db/migrate-encryption-parity.test.ts b/server/tests/unit/db/migrate-encryption-parity.test.ts
new file mode 100644
index 000000000..7b0a9584a
--- /dev/null
+++ b/server/tests/unit/db/migrate-encryption-parity.test.ts
@@ -0,0 +1,49 @@
+/**
+ * Parity guard for scripts/migrate-encryption.ts.
+ *
+ * The rotation script re-encrypts at-rest secrets from one ENCRYPTION_KEY to the
+ * next, and it walks a hardcoded list of app_settings keys. It cannot import the
+ * canonical list from src/, because it deliberately runs without config.ts and
+ * without the Nest container, so the list is a copy.
+ *
+ * A missing name does not fail loudly: the value stays encrypted under the old
+ * key, decrypt_api_key returns null on the next read, and the instance quietly
+ * behaves as if no key were configured. This pins the copy instead.
+ */
+import { describe, it, expect } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { INSTANCE_API_KEY_NAMES } from '../../../src/nest/settings/instance-api-keys';
+
+const script = fs.readFileSync(
+ path.join(__dirname, '..', '..', '..', 'scripts', 'migrate-encryption.ts'),
+ 'utf8',
+);
+
+// The app_settings loop only, so a name that happens to appear elsewhere in the
+// script (a users column of the same name, a comment) cannot satisfy the test.
+const appSettingsLoop = (() => {
+ const start = script.indexOf("'oidc_client_secret'");
+ expect(start).toBeGreaterThan(-1);
+ const end = script.indexOf('--- users:', start);
+ expect(end).toBeGreaterThan(start);
+ return script.slice(start, end);
+})();
+
+describe('migrate-encryption.ts app_settings parity', () => {
+ it('ROTPAR-001: rotates every instance-wide API key', () => {
+ for (const name of INSTANCE_API_KEY_NAMES) {
+ expect(appSettingsLoop).toContain(`'${name}'`);
+ }
+ });
+
+ it('ROTPAR-002: still rotates the app_settings secrets that predate them', () => {
+ for (const name of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
+ expect(appSettingsLoop).toContain(`'${name}'`);
+ }
+ });
+
+ it('ROTPAR-003: the canonical list is not empty, so ROTPAR-001 cannot pass vacuously', () => {
+ expect(INSTANCE_API_KEY_NAMES.length).toBeGreaterThan(0);
+ });
+});
diff --git a/server/tests/unit/mcp/tools-leg-transport-mode.test.ts b/server/tests/unit/mcp/tools-leg-transport-mode.test.ts
new file mode 100644
index 000000000..00f95c59e
--- /dev/null
+++ b/server/tests/unit/mcp/tools-leg-transport-mode.test.ts
@@ -0,0 +1,299 @@
+/**
+ * Unit tests for MCP leg-mode tools: set_leg_transport_mode,
+ * set_day_default_transport_mode.
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+
+const { testDb, dbMock } = vi.hoisted(() => {
+ const Database = require('better-sqlite3');
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec('PRAGMA busy_timeout = 5000');
+ const mock = {
+ db,
+ closeDb: () => {},
+ reinitialize: () => {},
+ getPlaceWithTags: () => null,
+ canAccessTrip: (tripId: any, userId: number) =>
+ db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
+ isOwner: (tripId: any, userId: number) =>
+ !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
+ };
+ return { testDb: db, dbMock: mock };
+});
+
+vi.mock('../../../src/db/database', () => dbMock);
+vi.mock('../../../src/config', () => ({
+ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
+ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
+ updateJwtSecret: () => {},
+}));
+
+const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
+vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
+
+import { createTables } from '../../../src/db/schema';
+import { runMigrations } from '../../../src/db/migrations';
+import { resetTestDb } from '../../helpers/test-db';
+import { createUser, createTrip, createDay, createPlace, createDayAssignment } from '../../helpers/factories';
+import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
+
+beforeAll(() => {
+ createTables(testDb);
+ runMigrations(testDb);
+});
+
+beforeEach(() => {
+ resetTestDb(testDb);
+ broadcastMock.mockClear();
+ delete process.env.DEMO_MODE;
+});
+
+afterAll(() => {
+ testDb.close();
+});
+
+async function withHarness(userId: number, fn: (h: McpHarness) => Promise) {
+ const h = await createMcpHarness({ userId, withResources: false });
+ try { await fn(h); } finally { await h.cleanup(); }
+}
+
+// ---------------------------------------------------------------------------
+// set_leg_transport_mode
+// ---------------------------------------------------------------------------
+
+describe('Tool: set_leg_transport_mode', () => {
+ it('sets the outgoing leg mode (default direction)', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ const place = createPlace(testDb, trip.id);
+ const assignment = createDayAssignment(testDb, day.id, place.id);
+ // Seed the incoming column so the assertion below proves it survives; the factory
+ // leaves it NULL, so without this it would pass whether or not the tool touched it.
+ testDb.prepare('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?').run('walking', assignment.id);
+
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_leg_transport_mode',
+ arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: 'cycling' },
+ });
+ const data = parseToolResult(result) as any;
+ expect(data.assignment.leg_transport_mode).toBe('cycling');
+ expect(data.assignment.incoming_leg_transport_mode).toBe('walking'); // outgoing must not touch incoming (panel item 4)
+ expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object));
+ });
+ });
+
+ it('sets the incoming leg mode without clobbering the outgoing column (column independence)', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ const place = createPlace(testDb, trip.id);
+ const assignment = createDayAssignment(testDb, day.id, place.id);
+ // Seed the outgoing column so we prove incoming does NOT touch it (panel item 4).
+ testDb.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run('driving', assignment.id);
+
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_leg_transport_mode',
+ arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: 'walking', direction: 'incoming' },
+ });
+ const data = parseToolResult(result) as any;
+ expect(data.assignment.incoming_leg_transport_mode).toBe('walking');
+ expect(data.assignment.leg_transport_mode).toBe('driving'); // outgoing untouched
+ });
+ });
+
+ it('rejects an assignment belonging to another trip (cross-trip join)', async () => {
+ // Proves the `AND d.trip_id = ?` join in getAssignmentForTrip; a 99999 id cannot.
+ const { user } = createUser(testDb);
+ const trip1 = createTrip(testDb, user.id);
+ const trip2 = createTrip(testDb, user.id);
+ const day2 = createDay(testDb, trip2.id);
+ const place2 = createPlace(testDb, trip2.id);
+ const assignment2 = createDayAssignment(testDb, day2.id, place2.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_leg_transport_mode',
+ arguments: { tripId: trip1.id, assignmentId: assignment2.id, transport_mode: 'walking' },
+ });
+ expect(result.isError).toBe(true);
+ });
+ });
+
+ it('clears the leg mode with null (inherit day default)', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ const place = createPlace(testDb, trip.id);
+ const assignment = createDayAssignment(testDb, day.id, place.id);
+ testDb.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run('cycling', assignment.id);
+
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_leg_transport_mode',
+ arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: null },
+ });
+ const data = parseToolResult(result) as any;
+ expect(data.assignment.leg_transport_mode).toBeNull();
+ });
+ });
+
+ it('returns error when assignment not found', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_leg_transport_mode',
+ arguments: { tripId: trip.id, assignmentId: 99999, transport_mode: 'walking' },
+ });
+ expect(result.isError).toBe(true);
+ });
+ });
+
+ it('returns access denied for non-member', async () => {
+ const { user } = createUser(testDb);
+ const { user: other } = createUser(testDb);
+ const trip = createTrip(testDb, other.id);
+ const day = createDay(testDb, trip.id);
+ const place = createPlace(testDb, trip.id);
+ const assignment = createDayAssignment(testDb, day.id, place.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_leg_transport_mode',
+ arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: 'walking' },
+ });
+ expect(result.isError).toBe(true);
+ });
+ });
+
+ it('blocks demo user', async () => {
+ process.env.DEMO_MODE = 'true';
+ const { user } = createUser(testDb, { email: 'demo@nomad.app' });
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ const place = createPlace(testDb, trip.id);
+ const assignment = createDayAssignment(testDb, day.id, place.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_leg_transport_mode',
+ arguments: { tripId: trip.id, assignmentId: assignment.id, transport_mode: 'walking' },
+ });
+ expect(result.isError).toBe(true);
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// set_day_default_transport_mode
+// ---------------------------------------------------------------------------
+
+describe('Tool: set_day_default_transport_mode', () => {
+ it('sets the day default transport mode', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_day_default_transport_mode',
+ arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'driving' },
+ });
+ const data = parseToolResult(result) as any;
+ expect(data.day.default_transport_mode).toBe('driving');
+ expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:updated', expect.any(Object));
+ });
+ });
+
+ it('clears the day default with null', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ testDb.prepare('UPDATE days SET default_transport_mode = ? WHERE id = ?').run('driving', day.id);
+
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_day_default_transport_mode',
+ arguments: { tripId: trip.id, dayId: day.id, transport_mode: null },
+ });
+ const data = parseToolResult(result) as any;
+ expect(data.day.default_transport_mode).toBeNull();
+ });
+ });
+
+ it('returns the day with its populated assignments array', async () => {
+ // setDefaultTransportMode returns { ...day, assignments }; exercise the non-empty case (panel item 4).
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ const place = createPlace(testDb, trip.id);
+ const assignment = createDayAssignment(testDb, day.id, place.id);
+
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_day_default_transport_mode',
+ arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'driving' },
+ });
+ const data = parseToolResult(result) as any;
+ expect(Array.isArray(data.day.assignments)).toBe(true);
+ expect(data.day.assignments).toHaveLength(1);
+ expect(data.day.assignments[0].id).toBe(assignment.id);
+ });
+ });
+
+ it('rejects a day belonging to another trip (cross-trip join)', async () => {
+ const { user } = createUser(testDb);
+ const trip1 = createTrip(testDb, user.id);
+ const trip2 = createTrip(testDb, user.id);
+ const day2 = createDay(testDb, trip2.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_day_default_transport_mode',
+ arguments: { tripId: trip1.id, dayId: day2.id, transport_mode: 'walking' },
+ });
+ expect(result.isError).toBe(true);
+ });
+ });
+
+ it('returns error when day not found', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_day_default_transport_mode',
+ arguments: { tripId: trip.id, dayId: 99999, transport_mode: 'walking' },
+ });
+ expect(result.isError).toBe(true);
+ });
+ });
+
+ it('returns access denied for non-member', async () => {
+ const { user } = createUser(testDb);
+ const { user: other } = createUser(testDb);
+ const trip = createTrip(testDb, other.id);
+ const day = createDay(testDb, trip.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_day_default_transport_mode',
+ arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'walking' },
+ });
+ expect(result.isError).toBe(true);
+ });
+ });
+
+ it('blocks demo user', async () => {
+ process.env.DEMO_MODE = 'true';
+ const { user } = createUser(testDb, { email: 'demo@nomad.app' });
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id);
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({
+ name: 'set_day_default_transport_mode',
+ arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'walking' },
+ });
+ expect(result.isError).toBe(true);
+ });
+ });
+});
diff --git a/server/tests/unit/mcp/tools-transports.test.ts b/server/tests/unit/mcp/tools-transports.test.ts
index 65fe84f40..f118cb353 100644
--- a/server/tests/unit/mcp/tools-transports.test.ts
+++ b/server/tests/unit/mcp/tools-transports.test.ts
@@ -506,6 +506,33 @@ describe('Tool: create_transport (multi-leg)', () => {
});
});
+ it('stores a per-segment booking reference and leaves the booking-level one alone (#1943)', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ await withHarness(user.id, async (h) => {
+ const data = parseToolResult(await h.client.callTool({
+ name: 'create_transport',
+ arguments: {
+ tripId: trip.id, type: 'flight', title: 'AMS to FCO',
+ endpoints: stopoverEndpoints,
+ legs: [
+ { from: 'AMS', to: 'CDG', confirmation_number: 'ABC123' },
+ { from: 'CDG', to: 'FCO' },
+ ],
+ },
+ })) as any;
+ const meta = storedMetadata(data.reservation.id);
+ expect(meta.legs[0].confirmation_number).toBe('ABC123');
+ // A leg without one gets no key at all, rather than an undefined that
+ // serialises away and back as null.
+ expect(Object.keys(meta.legs[1])).not.toContain('confirmation_number');
+ // The reference of a single segment must never become the booking's: that
+ // is a column of its own, fed only by the tool's own parameter.
+ expect(meta.confirmation_number).toBeUndefined();
+ expect(data.reservation.confirmation_number).toBeNull();
+ });
+ });
+
it('never overwrites a flat key the caller set itself', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -806,6 +833,29 @@ describe('Tool: update_transport (multi-leg)', () => {
});
});
+ it('keeps a per-segment booking reference through a legs-only update (#1943)', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ await withHarness(user.id, async (h) => {
+ const created = await seedStopover(h, trip.id);
+ const result = await h.client.callTool({
+ name: 'update_transport',
+ arguments: {
+ tripId: trip.id, reservationId: created.reservation.id,
+ legs: [
+ { from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00', confirmation_number: 'ABC123' },
+ { from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00', confirmation_number: 'XYZ789' },
+ ],
+ },
+ });
+ const meta = storedMetadata((parseToolResult(result) as any).reservation.id);
+ // The copy-through list in applyLegs is what carries the field: a field
+ // missing from it is accepted by the schema and then silently dropped.
+ expect(meta.legs.map((l: any) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
+ expect(meta.confirmation_number).toBeUndefined();
+ });
+ });
+
it('uses the supplied metadata as the base when both are sent', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
diff --git a/server/tests/unit/nest/atlas-transfer-endpoints.test.ts b/server/tests/unit/nest/atlas-transfer-endpoints.test.ts
new file mode 100644
index 000000000..033f29f2a
--- /dev/null
+++ b/server/tests/unit/nest/atlas-transfer-endpoints.test.ts
@@ -0,0 +1,337 @@
+/**
+ * The layover pairing behind #1535. Pure, so it gets its own file and no DB: the
+ * whole question is which two reservation_endpoints rows describe one plane change
+ * spread over two bookings, and every case below is a shape the AirTrail import
+ * really produces (timed legs, date-only legs, a leg whose booking clock belongs
+ * to the other airport).
+ */
+import { describe, it, expect } from 'vitest';
+
+import {
+ MAX_CLOCKLESS_DAY_GAP,
+ MAX_LAYOVER_MS,
+ transferEndpointIds,
+ type FlightEndpointRow,
+} from '../../../src/nest/atlas/transfer-endpoints';
+
+interface Airport {
+ code: string | null;
+ lat: number;
+ lng: number;
+}
+
+const BRU: Airport = { code: 'BRU', lat: 50.9014, lng: 4.4844 };
+const HEL: Airport = { code: 'HEL', lat: 60.3172, lng: 24.9633 };
+const JFK: Airport = { code: 'JFK', lat: 40.6413, lng: -73.7781 };
+
+/** Brussels' other airport, 45 km from BRU: the one a cheap return lands at. */
+const CRL: Airport = { code: 'CRL', lat: 50.4592, lng: 4.4538 };
+/** Amsterdam, 170 km from Brussels, which is two cities and not one. */
+const AMS: Airport = { code: 'AMS', lat: 52.3105, lng: 4.7683 };
+const FRA: Airport = { code: 'FRA', lat: 50.0379, lng: 8.5622 };
+
+/** Same airport as HEL, but geocoded instead of imported, so no IATA code. */
+const HEL_NO_CODE: Airport = { code: null, lat: 60.3174, lng: 24.9629 };
+
+interface Clock {
+ date?: string | null;
+ time?: string | null;
+ /** The booking's own reservation_time/_end_time, which is all a date-only leg has. */
+ fallback?: string | null;
+}
+
+let nextId = 0;
+
+function endpoint(
+ reservationId: number,
+ role: 'from' | 'to' | 'stop',
+ at: Airport,
+ clock: Clock = {},
+ tripId = 1,
+): FlightEndpointRow {
+ return {
+ id: ++nextId,
+ reservation_id: reservationId,
+ trip_id: tripId,
+ role,
+ code: at.code,
+ lat: at.lat,
+ lng: at.lng,
+ local_date: clock.date ?? null,
+ local_time: clock.time ?? null,
+ fallback_time: clock.fallback ?? null,
+ };
+}
+
+/**
+ * BRU → HEL as one booking and HEL → JFK as the next, which is what the importer
+ * writes when the two legs never chained. `arrival`/`departure` are the clocks at
+ * the hub, the only ones the pairing looks at.
+ */
+function splitChain(arrival: Clock, departure: Clock, hub: Airport = HEL, hubAgain: Airport = hub) {
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', hub, arrival),
+ endpoint(2, 'from', hubAgain, departure),
+ endpoint(2, 'to', JFK, { date: departure.date ?? '2026-08-01', time: '15:00' }),
+ ];
+ return { rows, hubIds: [rows[1].id, rows[2].id].sort((a, b) => a - b) };
+}
+
+const ids = (found: Set) => [...found].sort((a, b) => a - b);
+
+describe('transferEndpointIds', () => {
+ it('keeps the 24 h window the importers use to merge the legs of one journey', () => {
+ expect(MAX_LAYOVER_MS).toBe(24 * 3600 * 1000);
+ expect(MAX_CLOCKLESS_DAY_GAP).toBe(1);
+ });
+
+ it('pairs an arrival and an onward departure at the same airport code', () => {
+ const { rows, hubIds } = splitChain({ date: '2026-08-01', time: '09:30' }, { date: '2026-08-01', time: '11:00' });
+ expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
+ });
+
+ it('pairs on the coordinate when neither row carries a code', () => {
+ const { rows, hubIds } = splitChain(
+ { date: '2026-08-01', time: '09:30' },
+ { date: '2026-08-01', time: '11:00' },
+ HEL_NO_CODE,
+ );
+ expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
+ });
+
+ it('pairs a coded row with an uncoded one at the same coordinate', () => {
+ const { rows, hubIds } = splitChain(
+ { date: '2026-08-01', time: '09:30' },
+ { date: '2026-08-01', time: '11:00' },
+ HEL,
+ HEL_NO_CODE,
+ );
+ expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
+ });
+
+ it('matches codes case-insensitively', () => {
+ const { rows, hubIds } = splitChain(
+ { date: '2026-08-01', time: '09:30' },
+ { date: '2026-08-01', time: '11:00' },
+ { code: 'hel', lat: 1, lng: 1 },
+ { code: 'HEL', lat: 2, lng: 2 },
+ );
+ expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
+ });
+
+ it('pairs a turnaround with no ground time at all and one of exactly 24 h', () => {
+ for (const departure of [
+ { date: '2026-08-01', time: '09:30' },
+ { date: '2026-08-02', time: '09:30' },
+ ]) {
+ const { rows, hubIds } = splitChain({ date: '2026-08-01', time: '09:30' }, departure);
+ expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
+ }
+ });
+
+ it('leaves a stay of 24 h and a minute, or a departure before the arrival, counted', () => {
+ for (const departure of [
+ { date: '2026-08-02', time: '09:31' },
+ { date: '2026-08-01', time: '09:29' },
+ ]) {
+ const { rows } = splitChain({ date: '2026-08-01', time: '09:30' }, departure);
+ expect(transferEndpointIds(rows).size).toBe(0);
+ }
+ });
+
+ it('falls back to the calendar date when a leg has no clock, same day or the next', () => {
+ for (const date of ['2026-08-01', '2026-08-02']) {
+ const { rows, hubIds } = splitChain({ date: '2026-08-01' }, { date });
+ expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
+ }
+ });
+
+ it('treats an unreadable clock as no clock and pairs on the date', () => {
+ const { rows, hubIds } = splitChain({ date: '2026-08-01', time: 'midday' }, { date: '2026-08-01', time: '11:00' });
+ expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
+ });
+
+ it('keeps a hub the traveler left two days later', () => {
+ const { rows } = splitChain({ date: '2026-08-01' }, { date: '2026-08-03' });
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it('keeps a hub whose onward departure has no date at all', () => {
+ const { rows } = splitChain({ date: '2026-08-01', time: '09:30' }, {});
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it('reads the date out of the booking column but never its clock', () => {
+ // A date-only AirTrail flight leaves the arrival endpoint without local parts, and
+ // reservation_time holds the DEPARTURE airport's local clock, 22:00 in Brussels for
+ // a flight that landed in Helsinki that morning. Using it as an arrival time would
+ // make the onward 11:00 departure look like it left before the plane landed.
+ const { rows, hubIds } = splitChain(
+ { fallback: '2026-08-01T22:00' },
+ { date: '2026-08-01', time: '11:00' },
+ );
+ expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
+ });
+
+ it('never pairs the two ends of one booking', () => {
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
+ ];
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it('never pairs across two trips', () => {
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
+ endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }, 2),
+ endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }, 2),
+ ];
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it('never treats a same-day out-and-back as a connection', () => {
+ // Flown out in the morning, back in the evening: the day was spent in Finland.
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
+ endpoint(2, 'from', HEL, { date: '2026-08-01', time: '18:00' }),
+ endpoint(2, 'to', BRU, { date: '2026-08-01', time: '20:30' }),
+ ];
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it("never treats an out-and-back into the city's other airport as a connection", () => {
+ // Flown to Helsinki in the morning, back to Brussels in the evening, but the return
+ // lands at Charleroi instead of Zaventem. The day was still spent in Finland, and
+ // the airport keys alone can never see that the two ends are one city.
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
+ endpoint(2, 'from', HEL, { date: '2026-08-01', time: '18:00' }),
+ endpoint(2, 'to', CRL, { date: '2026-08-01', time: '20:30' }),
+ ];
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it('still pairs a hub between two airports that are their own cities', () => {
+ // Amsterdam to Frankfurt to Brussels is a real connection, and AMS and BRU are
+ // 170 km apart, so the radius that catches a sibling airport must not reach here.
+ const rows = [
+ endpoint(1, 'from', AMS, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', FRA, { date: '2026-08-01', time: '08:15' }),
+ endpoint(2, 'from', FRA, { date: '2026-08-01', time: '10:00' }),
+ endpoint(2, 'to', BRU, { date: '2026-08-01', time: '11:05' }),
+ ];
+ expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
+ });
+
+ it('keeps an overnight return through the sibling airport out of the pairing', () => {
+ // Same shape a night away on business has: 22.5 h on the ground, home into Gatwick
+ // instead of Heathrow. Still inside the 24 h window, so only the direction says no.
+ const LHR: Airport = { code: 'LHR', lat: 51.47, lng: -0.4543 };
+ const LGW: Airport = { code: 'LGW', lat: 51.1537, lng: -0.1821 };
+ const rows = [
+ endpoint(1, 'from', LHR, { date: '2026-08-01', time: '06:30' }),
+ endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
+ endpoint(2, 'from', HEL, { date: '2026-08-02', time: '08:00' }),
+ endpoint(2, 'to', LGW, { date: '2026-08-02', time: '09:45' }),
+ ];
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it('pairs only inside one trip, even where the hub and the clocks would fit', () => {
+ // Two journeys through Helsinki on one day. The second trip's arrival has no onward
+ // leg of its own, and the first trip's departure two hours later must not become one.
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
+ endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
+ endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
+ endpoint(3, 'from', BRU, { date: '2026-08-01', time: '06:00' }, 2),
+ endpoint(3, 'to', HEL, { date: '2026-08-01', time: '09:00' }, 2),
+ ];
+
+ expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
+ });
+
+ it('pairs across midnight when only one of the two legs carries a clock', () => {
+ // The scan looks ahead from the arrival's date, not from its instant, because a
+ // date-only leg is anchored on midnight: landing at 23:50 and leaving on a flight
+ // the next day is one connection, and so is a dateless landing left at 23:45.
+ const lateArrival = splitChain({ date: '2026-08-01', time: '23:50' }, { date: '2026-08-02' });
+ expect(ids(transferEndpointIds(lateArrival.rows))).toEqual(lateArrival.hubIds);
+
+ const lateDeparture = splitChain({ date: '2026-08-01' }, { date: '2026-08-02', time: '23:45' });
+ expect(ids(transferEndpointIds(lateDeparture.rows))).toEqual(lateDeparture.hubIds);
+ });
+
+ it('pairs nothing when neither side has a readable date', () => {
+ for (const clock of [{}, { fallback: 'sometime in August' }, { date: '01/08/2026' }]) {
+ const { rows } = splitChain(clock, clock);
+ expect(transferEndpointIds(rows).size).toBe(0);
+ }
+ });
+
+ it('pairs nothing when a date passes the shape check but is not a real date', () => {
+ for (const time of [null, '09:30']) {
+ const { rows } = splitChain({ date: '2026-13-45', time }, { date: '2026-13-45', time });
+ expect(transferEndpointIds(rows).size).toBe(0);
+ }
+ });
+
+ it('pairs an onward leg whose booking never got a second endpoint', () => {
+ // A leg AirTrail has no coordinates for loses that endpoint, so the onward booking
+ // has a 'from' and nothing else, which must not read as flying back to the origin.
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
+ endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
+ ];
+ expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
+ });
+
+ it('ignores a stop row rather than reading it as an arrival', () => {
+ // The callers filter roles in SQL, but a 'stop' is already handled by #1486 and must
+ // never become one half of a pair here.
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'stop', HEL, { date: '2026-08-01', time: '09:30' }),
+ endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
+ endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
+ ];
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it('ignores a row that can be keyed neither by code nor by coordinate', () => {
+ const { rows } = splitChain(
+ { date: '2026-08-01', time: '09:30' },
+ { date: '2026-08-01', time: '11:00' },
+ { code: null, lat: NaN, lng: NaN },
+ );
+ expect(transferEndpointIds(rows).size).toBe(0);
+ });
+
+ it('has nothing to pair with a single endpoint', () => {
+ expect(transferEndpointIds([endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' })]).size).toBe(0);
+ });
+
+ it('marks only the pair that matches when the same hub is used again later', () => {
+ // Out via Helsinki on the 1st, home via Helsinki on the 8th, but that time with
+ // three days in the city, so only the first pass is a plane change.
+ const rows = [
+ endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
+ endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
+ endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
+ endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
+ endpoint(3, 'from', JFK, { date: '2026-08-08', time: '01:00' }),
+ endpoint(3, 'to', HEL, { date: '2026-08-08', time: '10:00' }),
+ endpoint(4, 'from', HEL, { date: '2026-08-11', time: '12:00' }),
+ endpoint(4, 'to', BRU, { date: '2026-08-11', time: '14:30' }),
+ ];
+
+ expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
+ });
+});
diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts
index f29f8d49d..e35c1c1be 100644
--- a/server/tests/unit/nest/atlas.service.test.ts
+++ b/server/tests/unit/nest/atlas.service.test.ts
@@ -54,11 +54,14 @@ function insertReservationEndpoint(
role: 'from' | 'to' | 'stop',
sequence: number,
lat: number,
- lng: number
+ lng: number,
+ code: string | null = null,
+ localDate: string | null = null,
+ localTime: string | null = null
) {
db.prepare(
- 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)'
- ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
+ 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code, localDate, localTime);
}
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
@@ -202,6 +205,158 @@ describe('getStats', () => {
});
});
+// ── #1535: the layover the role filter can't see ─────────────────────────────
+
+/**
+ * When the legs of one journey arrive as two separate bookings (an AirTrail import
+ * whose flights never chained), the connection airport is the legitimate 'to' of the
+ * first and the legitimate 'from' of the second, so the role filter from #1486 lets
+ * it through. Brussels to Helsinki to New York throughout: BE and US were reached,
+ * Finland was a plane change.
+ */
+describe('getStats layover pairs across two bookings', () => {
+ const BRU = { lat: 50.9014, lng: 4.4844, code: 'BRU' };
+ const HEL = { lat: 60.3172, lng: 24.9633, code: 'HEL' };
+ const JFK = { lat: 40.6413, lng: -73.7781, code: 'JFK' };
+
+ type Clock = { date: string | null; time: string | null };
+
+ function inboundToHelsinki(tripId: number, arrival: Clock) {
+ const res = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' });
+ insertReservationEndpoint(testDb, res.id, 'from', 0, BRU.lat, BRU.lng, BRU.code, arrival.date, '07:00');
+ insertReservationEndpoint(testDb, res.id, 'to', 1, HEL.lat, HEL.lng, HEL.code, arrival.date, arrival.time);
+ return res;
+ }
+
+ function onwardFromHelsinki(tripId: number, departure: Clock, type = 'flight') {
+ const res = createReservation(testDb, tripId, { type, title: 'HEL-JFK' });
+ insertReservationEndpoint(testDb, res.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, departure.date, departure.time);
+ insertReservationEndpoint(testDb, res.id, 'to', 1, JFK.lat, JFK.lng, JFK.code, departure.date, '15:00');
+ return res;
+ }
+
+ const codesFor = async (userId: number) =>
+ (await atlas.stats(userId)).countries.map((c: { code: string }) => c.code);
+
+ it('ATLAS-UNIT-044 (#1535): a hub arrived at and left again the same day is not visited', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
+ inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
+ onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
+
+ const codes = await codesFor(user.id);
+ expect(codes).toContain('BE');
+ expect(codes).toContain('US');
+ expect(codes).not.toContain('FI');
+ });
+
+ it('ATLAS-UNIT-045 (#1535): a hub the traveler left two days later stays visited', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover' });
+ inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
+ onwardFromHelsinki(trip.id, { date: '2026-08-03', time: '11:00' });
+
+ expect(await codesFor(user.id)).toContain('FI');
+ });
+
+ it('ATLAS-UNIT-046 (#1535): a pair with no clocks pairs on the booking dates', async () => {
+ // The case the reporter actually has: a date-only AirTrail flight leaves the
+ // endpoints without local parts, so only reservation_time carries the day.
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
+ const inbound = inboundToHelsinki(trip.id, { date: null, time: null });
+ const onward = onwardFromHelsinki(trip.id, { date: null, time: null });
+ const setTime = testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
+ setTime.run('2026-08-01', inbound.id);
+ setTime.run('2026-08-01', onward.id);
+
+ const codes = await codesFor(user.id);
+ expect(codes).toContain('BE');
+ expect(codes).toContain('US');
+ expect(codes).not.toContain('FI');
+ });
+
+ it('ATLAS-UNIT-047 (#1535): clockless bookings two days apart keep the hub visited', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover' });
+ const inbound = inboundToHelsinki(trip.id, { date: null, time: null });
+ const onward = onwardFromHelsinki(trip.id, { date: null, time: null });
+ const setTime = testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
+ setTime.run('2026-08-01', inbound.id);
+ setTime.run('2026-08-03', onward.id);
+
+ expect(await codesFor(user.id)).toContain('FI');
+ });
+
+ it('ATLAS-UNIT-048 (#1535): a place in the layover country keeps it visited', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
+ inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
+ onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
+ insertPlace(testDb, trip.id, 'Kamppi Chapel', 'Simonkatu 7, Helsinki, Finland');
+
+ expect(await codesFor(user.id)).toContain('FI');
+ });
+
+ it('ATLAS-UNIT-049 (#1366 guard): a car rental picked up where the flight landed keeps the country', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Driving Finland' });
+ inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
+ onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' }, 'car_rental');
+
+ expect(await codesFor(user.id)).toContain('FI');
+ });
+
+ it('ATLAS-UNIT-050 (#1535): a cancelled onward flight is no connection', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Helsinki, stuck' });
+ inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
+ const onward = onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
+ testDb.prepare('UPDATE reservations SET status = ? WHERE id = ?').run('cancelled', onward.id);
+
+ expect(await codesFor(user.id)).toContain('FI');
+ });
+
+ it('ATLAS-UNIT-051 (#1535): an onward flight on another trip is another journey', async () => {
+ // Landing home from one trip and leaving on the next within a day is not a plane
+ // change, and the pairing spans every trip the user can see.
+ const { user } = createUser(testDb);
+ const arriving = createTrip(testDb, user.id, { title: 'Back from Brussels' });
+ const leaving = createTrip(testDb, user.id, { title: 'Off to New York' });
+ inboundToHelsinki(arriving.id, { date: '2026-08-01', time: '09:30' });
+ onwardFromHelsinki(leaving.id, { date: '2026-08-01', time: '11:00' });
+
+ expect(await codesFor(user.id)).toContain('FI');
+ });
+
+ it('ATLAS-UNIT-052 (#1535): a same-day out-and-back is a day in the country, not a transfer', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'A day in Helsinki' });
+ inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
+ const back = createReservation(testDb, trip.id, { type: 'flight', title: 'HEL-BRU' });
+ insertReservationEndpoint(testDb, back.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, '2026-08-01', '18:00');
+ insertReservationEndpoint(testDb, back.id, 'to', 1, BRU.lat, BRU.lng, BRU.code, '2026-08-01', '20:30');
+
+ expect(await codesFor(user.id)).toContain('FI');
+ });
+
+ it('ATLAS-UNIT-053 (#1535): a return into the other airport of the same city is still a day away', async () => {
+ // Same day trip as ATLAS-UNIT-052, but home into Charleroi instead of Zaventem, the
+ // way a cheap return is booked. Comparing the airports alone reads that as flying
+ // onwards and drops Finland, though the traveler spent the day in Helsinki.
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'A day in Helsinki' });
+ inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
+ const back = createReservation(testDb, trip.id, { type: 'flight', title: 'HEL-CRL' });
+ insertReservationEndpoint(testDb, back.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, '2026-08-01', '18:00');
+ insertReservationEndpoint(testDb, back.id, 'to', 1, 50.4592, 4.4538, 'CRL', '2026-08-01', '20:30');
+
+ const codes = await codesFor(user.id);
+ expect(codes).toContain('FI');
+ expect(codes).toContain('BE');
+ });
+});
+
// ── the shared geocode cache ──────────────────────────────────────
/**
diff --git a/server/tests/unit/nest/audit.service.test.ts b/server/tests/unit/nest/audit.service.test.ts
index f64c320ad..3c9499218 100644
--- a/server/tests/unit/nest/audit.service.test.ts
+++ b/server/tests/unit/nest/audit.service.test.ts
@@ -170,6 +170,24 @@ describe('writeAudit', () => {
testDb.prepare('ALTER TABLE audit_log_gone RENAME TO audit_log').run();
});
+ it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', () => {
+ seedUser(1, 'admin@b.c');
+ svc.writeAudit({
+ userId: 1,
+ action: 'settings.api_keys_update',
+ resource: 'api_keys',
+ details: { changed: ['maps_api_key', 'unsplash_api_key'] },
+ ip: '1.2.3.4',
+ });
+ expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys (maps_api_key, unsplash_api_key) ip=1.2.3.4');
+ // A details blob without the array (or with an empty one) leaves the brief off
+ // rather than stringifying whatever else is in there.
+ svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { changed: [] }, ip: '1.2.3.4' });
+ expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys ip=1.2.3.4');
+ svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', details: { other: 'x' }, ip: '1.2.3.4' });
+ expect(logInfo).toHaveBeenLastCalledWith('admin@b.c updated API keys ip=1.2.3.4');
+ });
+
it('AUDIT-SVC-014: buildInfoSummary variants (settings parts, login empty brief)', () => {
seedUser(1, 'a@b.c');
svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { notification_channel: 'smtp', require_mfa: false }, ip: '1.1.1.1' });
diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts
index 6f8578e26..b3f2afea2 100644
--- a/server/tests/unit/nest/auth.controller.test.ts
+++ b/server/tests/unit/nest/auth.controller.test.ts
@@ -290,15 +290,52 @@ describe('AuthController (authenticated)', () => {
it('maps-key + api-keys pass straight through to the service', () => {
const updateMapsKey = vi.fn().mockReturnValue({ success: true });
- expect(ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true });
+ expect(ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' }, req)).toEqual({ success: true });
expect(updateMapsKey).toHaveBeenCalledWith(1, 'k');
const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 });
- expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never))).toEqual({ ok: 1 });
+ expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never), req)).toEqual({ ok: 1 });
+ // No changedKeys from the service (and none in the body): nothing to audit.
+ expect(writeAudit).not.toHaveBeenCalled();
+ });
+
+ it('api-keys audits one settings.api_keys_update with the changed names only (#1939)', () => {
+ const updateApiKeys = vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['maps_api_key'] });
+ const c = ac(asvc({}), rl(), {}, { updateApiKeys });
+ // changedKeys is stripped off the response — the client body is unchanged.
+ expect(c.apiKeys(user, anyBody({ maps_api_key: 'AIza-super-secret' } as never), req)).toEqual({ success: true, user: { id: 1 } });
+ expect(writeAudit).toHaveBeenCalledTimes(1);
+ expect(writeAudit).toHaveBeenCalledWith({
+ userId: 1,
+ action: 'settings.api_keys_update',
+ resource: 'api_keys',
+ ip: '1.2.3.4',
+ details: { changed: ['maps_api_key'] },
+ });
+ // Never the value, not even truncated: details is rendered raw in the panel.
+ expect(JSON.stringify(writeAudit.mock.calls)).not.toContain('AIza-super-secret');
+ });
+
+ it('maps-key and settings audit the same action; an unchanged save writes nothing', () => {
+ const mapsC = ac(asvc({}), rl(), {}, { updateMapsKey: vi.fn().mockReturnValue({ success: true, changedKeys: ['maps_api_key'] }) });
+ mapsC.mapsKey(user, { maps_api_key: 'k' }, req);
+ expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['maps_api_key'] } }));
+
+ writeAudit.mockClear();
+ const setC = ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['unsplash_api_key'] }) });
+ expect(setC.updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
+ expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['unsplash_api_key'] } }));
+
+ // The test button in the panel saves before every click — an identical value
+ // must not cost a log line.
+ writeAudit.mockClear();
+ ac(asvc({}), rl(), {}, { updateApiKeys: vi.fn().mockReturnValue({ success: true, changedKeys: [] }) })
+ .apiKeys(user, anyBody({ maps_api_key: 'same' } as never), req);
+ expect(writeAudit).not.toHaveBeenCalled();
});
it('update-settings + get-settings map errors, else return their payloads', () => {
- expect(thrown(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } });
- expect(ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } });
+ expect(thrown(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } });
+ expect(ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
expect(thrown(() => ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) }).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
expect(ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) }).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
});
diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts
index 5ec1b1ef2..cf04e4e77 100644
--- a/server/tests/unit/nest/calendar.service.test.ts
+++ b/server/tests/unit/nest/calendar.service.test.ts
@@ -595,6 +595,44 @@ describe('exportICS', () => {
expect(ics).not.toContain('Route:');
});
+ it('CAL-043: a segment with its own booking reference gets its own Confirmation line (#1943)', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Layover' });
+ const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
+ testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
+ '2025-06-02T09:00', 'BOOK1',
+ JSON.stringify({ legs: [
+ { from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
+ { from: 'BER', to: 'HND', confirmation_number: 'XYZ789' },
+ ] }),
+ flight.id,
+ );
+
+ const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
+
+ expect(ics).toContain(
+ 'DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND'
+ + '\\nConfirmation FRA-BER: ABC123\\nConfirmation BER-HND: XYZ789\r\n'
+ );
+ });
+
+ it('CAL-044: a multi-leg flight without per-segment references reads exactly as before', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Layover' });
+ const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
+ testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
+ '2025-06-02T09:00', 'BOOK1',
+ JSON.stringify({ legs: [{ from: 'FRA', to: 'BER' }, { from: 'BER', to: 'HND' }] }),
+ flight.id,
+ );
+
+ const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
+
+ // Byte parity for every feed that exists today: the description ends after
+ // the route line, so no subscriber sees a changed event.
+ expect(ics).toContain('DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND\r\n');
+ });
+
it('CAL-020: an empty trip title falls back for SUMMARY, X-WR-CALNAME and the filename', () => {
const { user } = createUser(testDb);
// The title is only NOT NULL, not non-empty; an empty one used to produce
diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts
index 9fedb93fb..a9c7f3373 100644
--- a/server/tests/unit/nest/journey-domain.service.test.ts
+++ b/server/tests/unit/nest/journey-domain.service.test.ts
@@ -1411,7 +1411,11 @@ describe('Edge cases', () => {
expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Fun'], cons: [] });
});
- it('JOURNEY-SVC-086: addTripToJourney syncs trip photos when present', () => {
+ // #1614 — photos live in journeys now. Linking a trip used to copy its
+ // trip_photos into the gallery; that surface lost its UI in 3.1.0, nothing
+ // writes to it on a newer install, and the copy was how a photo one member had
+ // chosen not to share could reach a journey at all.
+ it('JOURNEY-SVC-086: addTripToJourney no longer pulls trip photos into the gallery', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, {
@@ -1421,16 +1425,10 @@ describe('Edge cases', () => {
});
addTripPhoto(testDb, trip.id, user.id, 'immich-photo-1', 'immich', { shared: true });
- svc.addTripToJourney(journey.id, trip.id, user.id);
+ expect(svc.addTripToJourney(journey.id, trip.id, user.id)).toBe(true);
- // Trip photos now go straight into the journey gallery (no wrapper entry).
- const photos = testDb.prepare(`
- SELECT jp.*, tkp.asset_id FROM journey_photos jp
- JOIN trek_photos tkp ON tkp.id = jp.photo_id
- WHERE jp.journey_id = ?
- `).all(journey.id);
- expect(photos.length).toBe(1);
- expect((photos[0] as any).asset_id).toBe('immich-photo-1');
+ const photos = testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id);
+ expect(photos).toHaveLength(0);
});
it('JOURNEY-SVC-087: removeTripFromJourney detaches filled entries, deletes skeletons', () => {
@@ -2079,3 +2077,46 @@ describe('journeyTracks', () => {
expect(svc.journeyTracks(journey.id, stranger.id)).toBeNull();
});
});
+
+// ── Trip linking: whose journey, and whose photos (#1614 review) ─────────────
+
+describe('addTripToJourney guards', () => {
+ it('JOURNEY-SVC-100: refuses to link into a journey the caller cannot reach', () => {
+ const { user: owner } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const journey = createJourney(testDb, owner.id, { title: "Owner's journey" });
+ const trip = createTrip(testDb, stranger.id, { title: 'Stranger trip' });
+
+ // The stranger owns the trip, so the trip gate passes — only the journey gate stops this.
+ expect(svc.addTripToJourney(journey.id, trip.id, stranger.id)).toBe(false);
+ const links = testDb.prepare('SELECT * FROM journey_trips WHERE journey_id = ?').all(journey.id);
+ expect(links).toHaveLength(0);
+ });
+
+ it('JOURNEY-SVC-101: a contributor may still link, the owner obviously too', () => {
+ const { user: owner } = createUser(testDb);
+ const { user: helper } = createUser(testDb);
+ const journey = createJourney(testDb, owner.id, { title: 'Shared journey' });
+ testDb.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)')
+ .run(journey.id, helper.id, 'editor', new Date().toISOString());
+ const trip = createTrip(testDb, helper.id, { title: 'Helper trip' });
+
+ expect(svc.addTripToJourney(journey.id, trip.id, helper.id)).toBe(true);
+ });
+
+ it('JOURNEY-SVC-102: not even a shared trip photo is copied any more', () => {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id, { title: 'Photo journey' });
+ const trip = createTrip(testDb, user.id, { title: 'Photo trip' });
+
+ const r = testDb.prepare(
+ "INSERT INTO trek_photos (provider, asset_id, owner_id, media_type) VALUES ('immich', 'shared-asset', ?, 'image')",
+ ).run(user.id);
+ testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)')
+ .run(trip.id, user.id, Number(r.lastInsertRowid));
+
+ svc.addTripToJourney(journey.id, trip.id, user.id);
+
+ expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id)).toHaveLength(0);
+ });
+});
diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts
index 0f48104a0..005f946ef 100644
--- a/server/tests/unit/nest/journey-share.service.test.ts
+++ b/server/tests/unit/nest/journey-share.service.test.ts
@@ -495,6 +495,55 @@ describe('getPublicJourney', () => {
expect(e.story).toBeUndefined(); // narrative withheld
});
+ // #1614 — a photo now carries the coordinates it was taken at. That is a place
+ // the owner never typed, so it has to follow the same switch the entry
+ // coordinates follow rather than riding in on the gallery flag.
+ it('JOURNEY-SHARE-025: withholds photo capture coordinates when the map flag is off', () => {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ const entry = createJourneyEntry(testDb, journey.id, user.id, {
+ type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01',
+ });
+ const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id });
+ testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ?, taken_at = ? WHERE id = ?')
+ .run(48.8584, 2.2945, '2026-05-01T10:00:00Z', trekId);
+
+ const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
+ share_timeline: true, share_gallery: true, share_map: false,
+ });
+
+ const result = svc.getPublicJourney(token)!;
+ const gallery = result.gallery as Record[];
+ expect(gallery).toHaveLength(1);
+ expect(gallery[0].lat).toBeNull();
+ expect(gallery[0].lng).toBeNull();
+ // The capture time is not a location and stays — it is what a blog view sorts by.
+ expect(gallery[0].taken_at).toBe('2026-05-01T10:00:00Z');
+
+ const inline = (result.entries[0] as Record).photos as Record[];
+ expect(inline[0].lat).toBeNull();
+ expect(inline[0].lng).toBeNull();
+ });
+
+ it('JOURNEY-SHARE-026: hands out photo coordinates once the map is shared', () => {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ const entry = createJourneyEntry(testDb, journey.id, user.id, {
+ type: 'entry', title: 'Day 1', entry_date: '2026-05-01',
+ });
+ const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id });
+ testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ? WHERE id = ?')
+ .run(48.8584, 2.2945, trekId);
+
+ const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
+ share_timeline: true, share_gallery: true, share_map: true,
+ });
+
+ const gallery = svc.getPublicJourney(token)!.gallery as Record[];
+ expect(gallery[0].lat).toBe(48.8584);
+ expect(gallery[0].lng).toBe(2.2945);
+ });
+
it('JOURNEY-SHARE-024: strips inline entry photos (and their asset metadata) when the gallery is off', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
diff --git a/server/tests/unit/nest/journey.controller.test.ts b/server/tests/unit/nest/journey.controller.test.ts
index 65e6191f0..3fc07be95 100644
--- a/server/tests/unit/nest/journey.controller.test.ts
+++ b/server/tests/unit/nest/journey.controller.test.ts
@@ -4,6 +4,7 @@ import type { Response } from 'express';
import { JourneyController } from '../../../src/nest/journey/journey.controller';
import { journeyThumbName } from '../../../src/nest/memories/thumbnail.service';
+import type { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
import type { JourneyService } from '../../../src/nest/journey/journey.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
@@ -23,6 +24,9 @@ const storageStub = {
exists: storageExists,
sendToResponse: storageSendToResponse,
} as unknown as StorageService;
+// The capture backfill is detached and irrelevant to every case here — it only has
+// to exist so the constructor can call it.
+const captureBackfillStub = { schedule: vi.fn(), run: vi.fn() } as unknown as PhotoCaptureBackfillService;
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
@@ -45,130 +49,130 @@ beforeEach(() => vi.clearAllMocks());
describe('JourneyController', () => {
it('GET / lists; POST / 400 without title, else creates', () => {
- expect(new JourneyController(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), storageStub).list(user)).toEqual({ journeys: [{ id: 1 }] });
- expect(thrown(() => new JourneyController(svc(), storageStub).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
+ expect(new JourneyController(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), storageStub, captureBackfillStub).list(user)).toEqual({ journeys: [{ id: 1 }] });
+ expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
const createJourney = vi.fn().mockReturnValue({ id: 9 });
- expect(new JourneyController(svc({ createJourney } as Partial), storageStub).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
+ expect(new JourneyController(svc({ createJourney } as Partial), storageStub, captureBackfillStub).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] });
});
it('GET /suggestions + /available-trips', () => {
- expect(new JourneyController(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), storageStub).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
- expect(new JourneyController(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial), storageStub).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
+ expect(new JourneyController(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), storageStub, captureBackfillStub).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
+ expect(new JourneyController(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial), storageStub, captureBackfillStub).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
});
it('PATCH/DELETE entries map 404', () => {
- expect(thrown(() => new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial), storageStub).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
- expect(new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial), storageStub).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
- expect(thrown(() => new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial), storageStub).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
- expect(new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial), storageStub).deleteEntry(user, '3')).toEqual({ success: true });
+ expect(thrown(() => new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
+ expect(new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial), storageStub, captureBackfillStub).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
+ expect(thrown(() => new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
+ expect(new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial), storageStub, captureBackfillStub).deleteEntry(user, '3')).toEqual({ success: true });
});
it('provider-photos: batch, single 400/403, success', () => {
const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial);
- expect(new JourneyController(batch, storageStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
- expect(thrown(() => new JourneyController(svc(), storageStub).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
- expect(thrown(() => new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
+ expect(new JourneyController(batch, storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
+ expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
+ expect(thrown(() => new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
});
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
- expect(thrown(() => new JourneyController(svc(), storageStub).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
+ expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 });
- const c = new JourneyController(svc({ linkPhotoToEntry } as Partial), storageStub);
+ const c = new JourneyController(svc({ linkPhotoToEntry } as Partial), storageStub, captureBackfillStub);
expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1);
// accepts the canonical journey_photo_id, 403 when the service refuses
- expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial), storageStub).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('unlink photo (204) maps 404; delete photo 404 then removes the object', async () => {
- expect(thrown(() => new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial), storageStub).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
- expect(new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial), storageStub).unlinkPhoto(user, '3', '7')).toBeUndefined();
- expect(await thrownAsync(() => new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
- expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial), storageStub).deletePhoto(user, '7')).toEqual({ success: true });
+ expect(thrown(() => new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
+ expect(new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial), storageStub, captureBackfillStub).unlinkPhoto(user, '3', '7')).toBeUndefined();
+ expect(await thrownAsync(() => new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
+ expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial), storageStub, captureBackfillStub).deletePhoto(user, '7')).toEqual({ success: true });
});
it('gallery upload 400 no files / 403 not allowed, else commits + returns photos', async () => {
- expect(await thrownAsync(() => new JourneyController(svc(), storageStub).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
- expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial), storageStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
- expect(await new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), storageStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
+ expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
+ expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(await new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
expect(storageStub.put).toHaveBeenCalledWith('journey', 'a.jpg', { tmpPath: undefined });
});
it('gallery video: 400 no video, 403 not allowed, else stores the clip + poster (#823)', async () => {
const files = { video: [{ filename: 'v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg' } as Express.Multer.File] };
- expect(await thrownAsync(() => new JourneyController(svc(), storageStub).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' } });
+ expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' } });
// Not-allowed after commit → the final objects are deleted through storage
// (the pre-commit unlink would miss them: file.path is already consumed).
const withPaths = { video: [{ filename: 'v.mp4', path: '/nonexistent/v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg', path: '/nonexistent/p.jpg' } as Express.Multer.File] };
- expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial), storageStub).uploadGalleryVideo(user, '3', withPaths, { duration_ms: 'abc' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', withPaths, { duration_ms: 'abc' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'v.mp4');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'p.jpg');
const up = vi.fn().mockReturnValue([{ id: 7 }]);
- expect(await new JourneyController(svc({ uploadGalleryPhotos: up } as Partial), storageStub).uploadGalleryVideo(user, '3', files, { duration_ms: '4200' })).toEqual({ photos: [{ id: 7 }] });
+ expect(await new JourneyController(svc({ uploadGalleryPhotos: up } as Partial), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', files, { duration_ms: '4200' })).toEqual({ photos: [{ id: 7 }] });
expect(up).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v.mp4', thumbnail: 'journey/p.jpg', mediaType: 'video', durationMs: 4200 }]);
// No poster + no duration → thumbnail undefined, durationMs null.
const up2 = vi.fn().mockReturnValue([{ id: 8 }]);
- await new JourneyController(svc({ uploadGalleryPhotos: up2 } as Partial), storageStub).uploadGalleryVideo(user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {});
+ await new JourneyController(svc({ uploadGalleryPhotos: up2 } as Partial), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {});
expect(up2).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v2.mp4', thumbnail: undefined, mediaType: 'video', durationMs: null }]);
});
it('provider-photos forwards per-asset media_types for gallery and entries (#823)', () => {
const add = vi.fn().mockReturnValue({ id: 1 });
- new JourneyController(svc({ addProviderPhotoToGallery: add } as Partial), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
+ new JourneyController(svc({ addProviderPhotoToGallery: add } as Partial), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video');
expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image');
const addOne = vi.fn().mockReturnValue({ id: 2 });
- new JourneyController(svc({ addProviderPhotoToGallery: addOne } as Partial), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
+ new JourneyController(svc({ addProviderPhotoToGallery: addOne } as Partial), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video');
// Entry path mirrors the gallery path.
const eAdd = vi.fn().mockReturnValue({ id: 3 });
- new JourneyController(svc({ addProviderPhoto: eAdd } as Partial), storageStub).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
+ new JourneyController(svc({ addProviderPhoto: eAdd } as Partial), storageStub, captureBackfillStub).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video');
const eOne = vi.fn().mockReturnValue({ id: 4 });
- new JourneyController(svc({ addProviderPhoto: eOne } as Partial), storageStub).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
+ new JourneyController(svc({ addProviderPhoto: eOne } as Partial), storageStub, captureBackfillStub).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video');
});
it('GET/PATCH/DELETE /:id map 404', () => {
- expect(thrown(() => new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial), storageStub).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
- expect(new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial), storageStub).get(user, '9')).toEqual({ id: 9 });
- expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial), storageStub).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
- expect(thrown(() => new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial), storageStub).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
+ expect(thrown(() => new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
+ expect(new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial), storageStub, captureBackfillStub).get(user, '9')).toEqual({ id: 9 });
+ expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
+ expect(thrown(() => new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
});
it('trips: POST 400 without trip_id / 403, DELETE 403', () => {
- expect(thrown(() => new JourneyController(svc(), storageStub).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
- expect(thrown(() => new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial), storageStub).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
- expect(new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial), storageStub).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
- expect(thrown(() => new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial), storageStub).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
+ expect(thrown(() => new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial), storageStub, captureBackfillStub).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
+ expect(thrown(() => new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entries under journey: list 404, create 400/404, reorder 400/403', () => {
- expect(thrown(() => new JourneyController(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial), storageStub).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
- expect(new JourneyController(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), storageStub).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
- expect(thrown(() => new JourneyController(svc(), storageStub).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
- expect(thrown(() => new JourneyController(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial), storageStub).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
- expect(thrown(() => new JourneyController(svc(), storageStub).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
- expect(thrown(() => new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial), storageStub).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(thrown(() => new JourneyController(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
+ expect(new JourneyController(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), storageStub, captureBackfillStub).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
+ expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
+ expect(thrown(() => new JourneyController(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
+ expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
+ expect(thrown(() => new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('contributors: add 400/403, update 403, remove 403', () => {
- expect(thrown(() => new JourneyController(svc(), storageStub).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
- expect(thrown(() => new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial), storageStub).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
- expect(new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial), storageStub).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
- expect(thrown(() => new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial), storageStub).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
- expect(thrown(() => new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial), storageStub).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
+ expect(thrown(() => new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
+ expect(thrown(() => new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(thrown(() => new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('preferences 403, share-link get/set/delete', () => {
- expect(thrown(() => new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial), storageStub).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
- expect(new JourneyController(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial), storageStub).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
- expect(thrown(() => new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial), storageStub).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
- expect(new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial), storageStub).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
- expect(thrown(() => new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial), storageStub).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(thrown(() => new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(new JourneyController(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial), storageStub, captureBackfillStub).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
+ expect(thrown(() => new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial), storageStub, captureBackfillStub).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
+ expect(thrown(() => new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial), storageStub, captureBackfillStub).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entry photo upload mirrors to Immich only when opted in', async () => {
@@ -176,76 +180,76 @@ describe('JourneyController', () => {
const uploadToImmich = vi.fn().mockResolvedValue('immich-1');
const setPhotoProvider = vi.fn();
const s = svc({ addPhoto, immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich, setPhotoProvider } as Partial);
- const res = await new JourneyController(s, storageStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
+ const res = await new JourneyController(s, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
expect(setPhotoProvider).toHaveBeenCalledWith(5, 'immich', 'immich-1', 1);
expect(res).toEqual({ photos: [{ id: 5, provider: 'immich', asset_id: 'immich-1', owner_id: 1 }] });
const noOptIn = svc({ addPhoto: vi.fn().mockReturnValue({ id: 6 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(false), uploadToImmich } as Partial);
- await new JourneyController(noOptIn, storageStub).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
+ await new JourneyController(noOptIn, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above
});
it('entry photo upload: 400 no files, 403 when nothing added, swallows immich errors and empty ids', async () => {
- expect(await thrownAsync(() => new JourneyController(svc(), storageStub).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
- expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
+ expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
+ expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
// opted in but the immich upload throws → best-effort, the local photo still wins
const setPhotoProvider = vi.fn();
const blowsUp = svc({ addPhoto: vi.fn().mockReturnValue({ id: 8 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockRejectedValue(new Error('immich down')), setPhotoProvider } as Partial);
- expect(await new JourneyController(blowsUp, storageStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
+ expect(await new JourneyController(blowsUp, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
expect(setPhotoProvider).not.toHaveBeenCalled();
// opted in but immich returns a falsy id → no provider stamping
const noId = svc({ addPhoto: vi.fn().mockReturnValue({ id: 9 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockResolvedValue(''), setPhotoProvider } as Partial);
- expect(await new JourneyController(noId, storageStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
+ expect(await new JourneyController(noId, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
});
it('provider-photos batch passes the passphrase through when present', () => {
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
- new JourneyController(svc({ addProviderPhoto } as Partial), storageStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
+ new JourneyController(svc({ addProviderPhoto } as Partial), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image');
// single-photo success path
- expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial), storageStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
+ expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
});
it('PATCH photos: 404 then returns the updated photo', () => {
- expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
- expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial), storageStub).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
+ expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
+ expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial), storageStub, captureBackfillStub).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
});
it('DELETE photo removes the storage object when a path exists', async () => {
- expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial), storageStub).deletePhoto(user, '7')).toEqual({ success: true });
+ expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial), storageStub, captureBackfillStub).deletePhoto(user, '7')).toEqual({ success: true });
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'a.jpg');
// ...and the derived thumb goes with it (spec fix #2).
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/a.jpg'));
// a vanished object is swallowed
(storageStub.delete as ReturnType).mockRejectedValueOnce(new Error('ENOENT'));
- expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial), storageStub).deletePhoto(user, '8')).toEqual({ success: true });
+ expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial), storageStub, captureBackfillStub).deletePhoto(user, '8')).toEqual({ success: true });
// a file_path outside the journey category never reaches storage
(storageStub.delete as ReturnType).mockClear();
- expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }) } as Partial), storageStub).deletePhoto(user, '9')).toEqual({ success: true });
+ expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }) } as Partial), storageStub, captureBackfillStub).deletePhoto(user, '9')).toEqual({ success: true });
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => {
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
- const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial), storageStub);
+ const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial), storageStub, captureBackfillStub);
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image');
- expect(thrown(() => new JourneyController(svc(), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
- expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
- expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial), storageStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
+ expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
+ expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
+ expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
});
it('DELETE gallery photo: 404, then removes the object when present', async () => {
- expect(await thrownAsync(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
+ expect(await thrownAsync(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
// no file_path → nothing to delete, returns void
- expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial), storageStub).deleteGalleryPhoto(user, '7')).toBeUndefined();
- await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial), storageStub).deleteGalleryPhoto(user, '8');
+ expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '7')).toBeUndefined();
+ await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '8');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'g.jpg');
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/g.jpg'));
(storageStub.delete as ReturnType).mockRejectedValueOnce(new Error('ENOENT'));
- expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial), storageStub).deleteGalleryPhoto(user, '9')).toBeUndefined();
+ expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '9')).toBeUndefined();
});
it('DELETE gallery video also reclaims its uploaded poster (spec fix #2 extension)', async () => {
@@ -253,6 +257,7 @@ describe('JourneyController', () => {
await new JourneyController(
svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 10, file_path: 'journey/clip.mp4', thumbnail_path: 'journey/poster.jpg' }) } as Partial),
storageStub,
+ captureBackfillStub,
).deleteGalleryPhoto(user, '10');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'clip.mp4');
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/clip.mp4'));
@@ -260,45 +265,45 @@ describe('JourneyController', () => {
});
it('PATCH /:id returns the updated journey on success', () => {
- expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial), storageStub).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
+ expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial