Compare commits

...

5 Commits

Author SHA1 Message Date
Maurice e79d0996ae Merge branches 'fix/mcp-trip-default-currency' and 'fix/mobile-reservation-detail-slot' into test/dev1-mcp-mobile 2026-09-22 09:59:05 +02:00
Maurice 8936049a81 fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 09:56:13 +02:00
Maurice c0b691b8e5 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 09:56:13 +02:00
Maurice aa64572bf0 fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false; the nested objects authored inline in the tool shapes are strict
for the same reason. A whole Zod schema, the way a plugin's manifest-built
tool arrives, passes through as its author wrote it.
2026-09-22 09:55:45 +02:00
Maurice 23aec0d898 fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 09:55:33 +02:00
29 changed files with 305 additions and 46 deletions
+7 -8
View File
@@ -320,14 +320,13 @@ export default function App() {
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// A version is a short release tag and nothing else. It arrives over the
// wire and is written to this device's storage, so it is rebuilt from the
// characters a tag may contain and kept only when nothing had to be
// stripped. Anything else is ignored, which also keeps a malformed value
// from being compared against the stored marker and starting an update on
// every launch.
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
if (version && version === reportedVersion) {
// wire and is written to this device's storage, so only a value made of
// the characters a tag may contain is taken, as the match itself. Anything
// else is ignored, which also keeps a malformed value from being compared
// against the stored marker and starting an update on every launch.
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
if (releaseTag) {
const version = releaseTag[0]
const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker
// after the purge and outside its try, so a throwing setItem (private
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -262,6 +262,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import {
TRANSPORT_TYPE_COLOR,
@@ -280,6 +280,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
import type { ReservationTraveler } from '@trek/shared'
import { avatarSrc } from '../../../../utils/avatarSrc'
import GuestBadge from '../../../../components/shared/GuestBadge'
import PluginFrame from '../../../../components/Plugins/PluginFrame'
import { usePluginStore } from '../../../../store/pluginStore'
/**
* Scroll body shared by the list-style trip tabs (transports, bookings, costs,
@@ -120,6 +122,36 @@ export function StatusDot({ color }: { color: string }) {
return <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} />
}
/**
* Plugins that declared a reservation-detail slot, mounted at the foot of a
* transport / booking card and scoped to that reservation: the phone half of
* what ReservationsPanel does on the desktop, which the phone cards never had,
* so a widget like Flight Tracker only appeared once the viewport crossed the
* desktop breakpoint (#2440). Sits outside the card's body button (buttons
* cannot nest, and a tap in the frame must not open the detail sheet), and the
* frame follows the host colour scheme like the desktop detail slots do, so a
* kit plugin that goes dark with TREK keeps compositing transparently.
*/
export function ReservationPluginSlots({ tripId, reservationId }: { tripId: number; reservationId: number }) {
const plugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'reservation-detail')
if (plugins.length === 0) return null
return (
<div className="flex flex-col gap-2 px-3 pb-3">
{plugins.map(p => (
<div key={p.id} className="overflow-hidden rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card">
<PluginFrame
pluginId={p.id}
tripId={String(tripId)}
reservationId={String(reservationId)}
title={p.name}
surface="detail-slot"
/>
</div>
))}
</div>
)
}
/**
* Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
* The mobile transport / booking cards only display them — assignment editing
@@ -3,17 +3,27 @@ import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { seedStore } from '../../../helpers/store'
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-018
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-023
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(),
}))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const SEAT_MAP = { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
@@ -93,6 +103,7 @@ function cardOf(title: string): HTMLElement {
describe('MBookingsTab', () => {
beforeEach(() => {
vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
})
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => {
@@ -285,4 +296,29 @@ describe('MBookingsTab', () => {
// the check-in cell and the time field both read 09:00
expect(within(card).getAllByText('09:00')).toHaveLength(2)
})
it('FE-MOB-BKTAB-022: mounts a reservation-detail plugin on every booking card, scoped to that booking (#2440)', () => {
seedStore(usePluginStore, {
plugins: [SEAT_MAP, { id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'sidebar' }],
})
const { planner: p } = renderTab()
const frame = within(cardOf('Hotel Granvia')).getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'seat-map')
expect(frame).toHaveAttribute('data-trip', '7')
expect(frame).toHaveAttribute('data-reservation', '201')
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
// the slot sits outside the card's body button: a tap in it opens no editor
fireEvent.click(frame)
expect(p.setShowReservationModal).not.toHaveBeenCalled()
expect(within(cardOf('Bamboo walk')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '204')
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(4)
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
})
it('FE-MOB-BKTAB-023: compact mode drops the plugin frames with the rest of the body', () => {
seedStore(usePluginStore, { plugins: [SEAT_MAP] })
renderTab(planner(), buildShell({ bookingsCompact: true }))
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
})
@@ -3,17 +3,27 @@ import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransports
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { seedStore } from '../../../helpers/store'
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-022
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-028
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(),
}))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const FLIGHT_TRACKER = { id: 'flight-tracker', name: 'Flight Tracker', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
@@ -101,6 +111,7 @@ function cardOf(title: string): HTMLElement {
describe('MTransportsTab', () => {
beforeEach(() => {
vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
})
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => {
@@ -322,4 +333,35 @@ describe('MTransportsTab', () => {
// nobody is assigned, so the traveler filter row stays away
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
})
it('FE-MOB-TRTAB-027: mounts a reservation-detail plugin on every card, scoped to that reservation (#2440)', () => {
seedStore(usePluginStore, {
plugins: [
FLIGHT_TRACKER,
{ id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'hero' },
{ id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'place-detail' },
],
})
const { shell } = renderTab()
const frame = within(cardOf('HND to ITM')).getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'flight-tracker')
expect(frame).toHaveAttribute('data-trip', '7')
expect(frame).toHaveAttribute('data-reservation', '101')
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
// the slot sits outside the card's body button: a tap in it opens no sheet
fireEvent.click(frame)
expect(shell.openSheet).not.toHaveBeenCalled()
expect(within(cardOf('Metro to hotel')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '104')
// one frame per transport card, and only for the reservation-detail slot
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(5)
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
expect(document.querySelector('[data-plugin="seat-map"]')).toBeNull()
})
it('FE-MOB-TRTAB-028: compact mode drops the plugin frames with the rest of the body', () => {
seedStore(usePluginStore, { plugins: [FLIGHT_TRACKER] })
renderTab(planner(), buildShell({ transportsCompact: true }))
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
})
+2 -2
View File
@@ -24,7 +24,7 @@ export class ThingsMcp {
@Tool({
name: 'list_things',
description: 'List all things.',
inputSchema: {}, // ZodRawShape, passed straight to the SDK
inputSchema: {}, // ZodRawShape, registered as a strict object
annotations: { readOnlyHint: true },
access: { group: 'things', mode: 'read' }, // resolved by the accessPolicy
})
@@ -34,7 +34,7 @@ export class ThingsMcp {
}
```
- `@Tool(options)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared.
- `@Tool(options)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared. A shape is registered as `z.strictObject`, so an argument the tool never declared is refused (`Unrecognized key`) and `tools/list` advertises `additionalProperties: false`; the SDK's own wrapping would strip it and run the handler regardless. A whole Zod schema is passed through as written.
- `@Resource(options)` — fixed URI (`uri`, `mimeType`, …). Handler: `(uri: URL, ctx)`.
- `@ResourceTemplate(options)` — `uriTemplate` (RFC 6570). Handler: `(uri: URL, variables, ctx)`. (No `list`/`complete` template callbacks yet — extend when a domain needs them.)
- `@Prompt(options)` — mirrors `registerPrompt` (`argsSchema` entries must be string-valued Zod schemas). Handler: `(args, ctx)`.
+16 -4
View File
@@ -15,12 +15,24 @@ import type {
} from './types';
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
interface BoundEntry {
entry: McpEntry;
instance: object;
}
/**
* A shape is registered strict: the SDK's own z.object wrapping would strip an
* undeclared key and run the handler as if nothing was wrong, and strict also
* puts `additionalProperties: false` into tools/list. A whole schema is the
* author's call and passes through untouched.
*/
function strictInputSchema(schema: ToolOptions['inputSchema']): unknown {
if (schema === undefined || '_zod' in schema) return schema;
return z.strictObject(schema);
}
export interface McpRegistryOptions {
accessPolicy?: McpAccessPolicy;
validateAccess?: McpAccessValidator;
@@ -30,9 +42,9 @@ type AnyHandler = (this: unknown, ...handlerArgs: unknown[]) => unknown;
/**
* Structural view of the SDK registration surface. The SDK's real signatures
* are generic over the Zod shapes; the registry passes schemas straight
* through and binds `ctx` where the SDK would pass `extra`, so it needs (and
* exposes) none of that type-level machinery.
* are generic over the Zod shapes; the registry hands schemas over as objects
* and binds `ctx` where the SDK would pass `extra`, so it needs (and exposes)
* none of that type-level machinery.
*/
interface LooseRegistrar {
registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown;
@@ -294,7 +306,7 @@ export class McpRegistry {
const config: Record<string, unknown> = {
title: options.title,
description: options.description,
inputSchema: options.inputSchema,
inputSchema: strictInputSchema(options.inputSchema),
outputSchema: options.outputSchema,
annotations: options.annotations,
_meta: options._meta,
+3 -2
View File
@@ -126,8 +126,9 @@ export interface McpZodSchema {
}
/**
* Mirrors the SDK's `registerTool` config. `inputSchema` is passed straight
* through — the SDK does the parsing/validation.
* Mirrors the SDK's `registerTool` config. A shape `inputSchema` is registered
* as a strict object (see registry.ts); a whole schema is passed as written. The
* SDK does the parsing/validation either way.
* `annotations`/`_meta` are kept structural so the package's public d.ts
* never imports deep SDK subpaths.
*/
+2 -2
View File
@@ -25,13 +25,13 @@ const budgetAddonOn = addonGate(ADDON_IDS.BUDGET);
* signed like the REST contract's (#2176): a negative payer is the recipient
* of a refund recorded as a negative expense.
*/
const payersSchema = z.array(z.object({
const payersSchema = z.array(z.strictObject({
user_id: z.number().int().positive(),
amount: z.number(),
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
/** Reusable Zod shape for an unequal split: what each participant owes. Signed, like the REST contract (#2176). */
const splitMembersSchema = z.array(z.object({
const splitMembersSchema = z.array(z.strictObject({
user_id: z.number().int().positive(),
amount: z.number(),
})).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.');
+1 -1
View File
@@ -63,7 +63,7 @@ type EntryVisibility = z.infer<typeof ENTRY_VISIBILITY>;
* only the half it has, which is what the entry editor does when a place was
* all good or all bad. The service stores nothing when both come back empty.
*/
const PROS_CONS = z.object({
const PROS_CONS = z.strictObject({
pros: z.array(z.string()).default([]),
cons: z.array(z.string()).default([]),
});
+1 -1
View File
@@ -95,7 +95,7 @@ export class MapsMcp {
z.enum(POI_CATEGORY_KEYS),
z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES),
]).describe('Which kind of place to look for. Pass several to cover them in one query rather than one request per kind'),
bbox: z.object({
bbox: z.strictObject({
south: z.number().min(-90).max(90).describe('Southern edge, latitude'),
west: z.number().min(-180).max(180).describe('Western edge, longitude'),
north: z.number().min(-90).max(90).describe('Northern edge, latitude'),
+1 -1
View File
@@ -511,7 +511,7 @@ export class PackingMcp {
description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.',
inputSchema: {
tripId: z.number().int().positive(),
items: z.array(z.object({
items: z.array(z.strictObject({
name: z.string().min(1).max(200),
category: z.string().optional(),
quantity: z.number().int().positive().optional(),
@@ -35,7 +35,7 @@ export class PluginSearchMcp {
description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.',
inputSchema: {
query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'),
near: z.object({ lat: z.number(), lng: z.number() })
near: z.strictObject({ lat: z.number(), lng: z.number() })
.optional()
.describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'),
lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'),
@@ -47,7 +47,7 @@ const urlField = reservationUrlSchema.max(2000).optional()
type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number];
type BookingType = typeof BOOKING_TYPES[number];
const endpointObjectSchema = z.object({
const endpointObjectSchema = z.strictObject({
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
@@ -520,7 +520,7 @@ export class ReservationsMcp {
description: 'Update the display order of reservations within a day.',
inputSchema: {
tripId: z.number().int().positive(),
positions: z.array(z.object({
positions: z.array(z.strictObject({
id: z.number().int().positive(),
day_plan_position: z.number().int().min(0),
})).describe('Array of { id, day_plan_position } pairs'),
+3 -3
View File
@@ -89,14 +89,14 @@ export class RoadtripMcp {
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
vias: z.array(z.object({
vias: z.array(z.strictObject({
after_order_index: z.number().int().min(0).describe('Which stop of the day this via follows, counting from 0'),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
})).max(100).describe('In the order the drive passes through them'),
replace_legs: z.array(z.number().int().min(0)).max(100).optional()
.describe('Legs to clear before inserting, by the index of the stop they follow'),
track: z.object({
track: z.strictObject({
place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'),
stray_km: z.number().min(0).max(40_000).nullable().optional()
.describe('How far the fitted route still runs from the track at its worst point'),
@@ -139,7 +139,7 @@ export class RoadtripMcp {
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
vias: z.array(z.object({
vias: z.array(z.strictObject({
id: z.number().int().positive(),
after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'),
})).max(500),
+1 -1
View File
@@ -88,7 +88,7 @@ export class TripsMcp {
description: z.string().max(2000).optional().describe('Trip description'),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD). Left out, the trip takes the display currency from the settings (see get_display_settings), or EUR when none is set.'),
day_count: z.number().int().min(1).max(MAX_TRIP_DAYS).optional().describe(
'How many days a trip without dates gets (default 7). Ignored when start_date and end_date are both set, because the range decides the count.'),
reminder_days: z.number().int().min(0).max(30).optional().describe(
+2 -1
View File
@@ -32,6 +32,7 @@ import { StorageModule } from '../storage/storage.module';
import { StorageService } from '../storage/storage.service';
import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
import { SettingsModule } from '../settings/settings.module';
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
* not capture the nested sub-domain mounts (collab, files, ...). */
@@ -48,7 +49,7 @@ import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
}),
}),
StorageModule,
McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule],
McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule, SettingsModule],
controllers: [TripsController],
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
+19 -5
View File
@@ -11,6 +11,7 @@ import { ReservationsService } from '../reservations/reservations.service';
import { VacayService } from '../vacay/vacay.service';
import { UnsplashService } from '../unsplash/unsplash.service';
import { StorageService } from '../storage/storage.service';
import { SettingsService } from '../settings/settings.service';
import { NotFoundError, ValidationError } from '../common/domain-errors';
export const MS_PER_DAY = 86400000;
@@ -134,15 +135,16 @@ export interface GuestMember {
/**
* Trip aggregate root, DI-native. Membership, the calendar export and the two
* read aggregates live in their own domains now; what is left is the write core
* plus day generation, which is why the constructor is eight parameters instead
* of fourteen. The SQL moved 1:1 from the legacy
* plus day generation, which is why the constructor is far shorter than the
* fourteen parameters it started with. The SQL moved 1:1 from the legacy
* services/tripService.ts: identical statements, the `||` falsy-coercion
* defaults, the post-write TRIP_SELECT re-selects and the mixed
* named/positional parameter styles are all preserved byte-for-byte.
* Post-migration quirk fixes on top of the 1:1 move: the multi-statement
* deletes (remove, deleteGuest's re-split + user delete) run in
* db.transaction(), and listMembers' owner row COALESCEs display_name like
* the member rows. Auth (canAccessTrip), per-field permission checks and
* db.transaction(), listMembers' owner row COALESCEs display_name like
* the member rows, and create() defaults the currency to the owner's display
* currency instead of the legacy EUR literal. Auth (canAccessTrip), per-field permission checks and
* audit logging stay in the controller (1:1 with the legacy route);
* trip:updated / trip:deleted broadcasts stay in the controller too — this
* service emits none.
@@ -159,12 +161,24 @@ export class TripsService {
private readonly realtime: RealtimeService,
private readonly unsplash: UnsplashService,
private readonly storage: StorageService,
private readonly settings: SettingsService,
) {}
private get db() {
return this.dbs.connection;
}
/**
* The currency a trip gets when the caller names none: the person's display
* currency, admin default merged in, else EUR. The trip dialog pre-fills the
* same value on the client, so a trip created through MCP or a plugin lands
* in the currency the person reads amounts in instead of always in EUR.
*/
private defaultCurrencyFor(userId: number): string {
const preferred = this.settings.getUserSettings(userId).default_currency;
return typeof preferred === 'string' && preferred.trim() ? preferred.trim() : 'EUR';
}
canAccessTrip(tripId: string | number, userId: number) {
return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
}
@@ -328,7 +342,7 @@ export class TripsService {
const result = this.db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd);
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || this.defaultCurrencyFor(userId), rd);
const tripId = result.lastInsertRowid;
this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count);
+14
View File
@@ -101,6 +101,9 @@ const { db } = vi.hoisted(() => {
// StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
// A trip created without a currency takes the owner's display currency, read
// off the per-user settings rows.
tmp.exec('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(user_id, key));');
return { db: tmp };
});
@@ -160,6 +163,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
db.prepare('DELETE FROM trip_members').run();
db.prepare('DELETE FROM days').run();
db.prepare('DELETE FROM audit_log').run();
db.prepare('DELETE FROM settings').run();
canAccessTrip.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true);
});
@@ -198,6 +202,16 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
expect(forbidden.status).toBe(403);
});
it('201 create without a currency takes the display currency from the settings', async () => {
db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'default_currency', ?)").run(JSON.stringify('USD'));
const preferred = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Road trip' });
expect(preferred.status).toBe(201);
expect(preferred.body.trip).toMatchObject({ title: 'Road trip', currency: 'USD' });
const explicit = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Tokyo', currency: 'JPY' });
expect(explicit.status).toBe(201);
expect(explicit.body.trip).toMatchObject({ title: 'Tokyo', currency: 'JPY' });
});
it('201 create keeps every day of a trip longer than a year (#2403)', async () => {
// 2025-01-26 .. 2026-01-28 is 368 days; the day list used to stop at 365.
const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1))
@@ -186,6 +186,7 @@ export function createMcpTestRegistry(): McpRegistry {
realtimeService,
new UnsplashService(dbService, new RuntimeEnvService(), generalStorage),
generalStorage,
new SettingsService(dbService),
);
const readModelService = new TripReadModelService(
dbService, membersService, daysService, accommodationsService, budgetService,
+1 -1
View File
@@ -113,7 +113,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations);
// After accommodations: a hotel booking writes the stay's day stop through it.
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations);
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage);
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage, new SettingsService(dbs));
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub());
const guards = new PluginGuards(dbs, permissions, addons);
@@ -247,6 +247,23 @@ describe('Tool: update_place', () => {
});
});
it('refuses a misspelt field instead of silently updating nothing', async () => {
// An assistant wrote stopType for stop_type; the call used to come back as a
// success with the place untouched, and the road trip never got its fuel stop.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Aral Bad Segeberg' });
await withHarness(user.id, async (h) => {
const refused = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, stopType: 'fuel' } });
expect(refused.isError).toBe(true);
expect((refused.content as Array<{ text: string }>)[0].text).toMatch(/Unrecognized key.*stopType/);
expect((testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id) as { stop_type: string | null }).stop_type).toBeNull();
expect(broadcastMock).not.toHaveBeenCalled();
const fixed = parseToolResult(await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, stop_type: 'fuel' } })) as any;
expect(fixed.place.stop_type).toBe('fuel');
});
});
it('sets image_url on an existing place (#37)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
+19
View File
@@ -204,6 +204,25 @@ describe('Tool: create_trip', () => {
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
});
});
it('gives a trip without a currency the display currency from the settings, not EUR', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?)").run(user.id, JSON.stringify('USD'));
await withHarness(user.id, async (h) => {
const fromSettings = parseToolResult(await h.client.callTool({ name: 'create_trip', arguments: { title: 'Road trip' } })) as any;
expect(fromSettings.trip.currency).toBe('USD');
const explicit = parseToolResult(await h.client.callTool({ name: 'create_trip', arguments: { title: 'Tokyo', currency: 'JPY' } })) as any;
expect(explicit.trip.currency).toBe('JPY');
});
});
it('falls back to EUR when neither the user nor the admin set a display currency', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({ name: 'create_trip', arguments: { title: 'Plain' } })) as any;
expect(data.trip.currency).toBe('EUR');
});
});
});
// ---------------------------------------------------------------------------
+36 -3
View File
@@ -43,6 +43,13 @@ class FixtureMcp {
return { content: [{ type: 'text', text: `wrote as ${ctx.userId}` }] };
}
// A whole schema, the way a plugin's manifest-built tool arrives: its author
// decided how open it is, and the registry must not tighten it.
@Tool({ name: 'loose_tool', inputSchema: z.looseObject({ name: z.string() }) })
async looseTool(args: Record<string, unknown>, ctx: TestCtx) {
return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] };
}
@Tool({ name: 'predicate_tool', access: (ctx: McpContext) => (ctx as TestCtx).allow === true })
async predicateTool(args: Record<string, never>, ctx: TestCtx) {
return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] };
@@ -150,7 +157,7 @@ describe('McpRegistry.attach', () => {
it('registers everything for a ctx passing all access checks', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: true, allow: true });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool', 'predicate_tool', 'read_tool', 'write_tool']);
expect(tools).toEqual(['loose_tool', 'open_tool', 'predicate_tool', 'read_tool', 'write_tool']);
const resources = (await harness.client.listResources()).resources.map((r) => r.name);
expect(resources).toEqual(['fixture_doc']);
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
@@ -162,13 +169,13 @@ describe('McpRegistry.attach', () => {
it('filters declarative access through the policy per mode', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: false, allow: false });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool', 'read_tool']);
expect(tools).toEqual(['loose_tool', 'open_tool', 'read_tool']);
});
it('filters everything gated when the ctx grants nothing, keeping ungated entries', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7 });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool']);
expect(tools).toEqual(['loose_tool', 'open_tool']);
expect((await harness.client.listResources()).resources).toEqual([]);
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
expect(templates).toEqual(['fixture_item']);
@@ -188,6 +195,32 @@ describe('McpRegistry.attach', () => {
expect(JSON.parse(textOf(result))).toEqual({ greeting: 'hello trek', userId: 42 });
});
it('refuses an argument the tool never declared instead of stripping it', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 42, canRead: true, canWrite: true, allow: true });
// A shape is registered strict: the SDK would otherwise drop `nmae` and run
// the handler as if the caller had sent nothing wrong.
const refused = await harness.client.callTool({ name: 'read_tool', arguments: { name: 'trek', nmae: 'typo' } });
expect(refused.isError).toBe(true);
expect(textOf(refused)).toMatch(/Unrecognized key.*nmae/);
// An empty shape is strict too: nothing declared means nothing accepted.
const stray = await harness.client.callTool({ name: 'write_tool', arguments: { anything: 1 } });
expect(stray.isError).toBe(true);
expect(textOf(stray)).toMatch(/Unrecognized key.*anything/);
expect(textOf(await harness.client.callTool({ name: 'write_tool', arguments: {} }))).toBe('wrote as 42');
// tools/list says so up front, so a client that reads the schema never sends it.
const tools = (await harness.client.listTools()).tools;
expect(tools.find((t) => t.name === 'read_tool')?.inputSchema).toMatchObject({ additionalProperties: false });
expect(tools.find((t) => t.name === 'write_tool')?.inputSchema).toMatchObject({ additionalProperties: false });
});
it('hands a whole schema to the SDK as written, however open its author left it', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 42 });
const result = await harness.client.callTool({ name: 'loose_tool', arguments: { name: 'trek', extra: 'kept' } });
expect(JSON.parse(textOf(result))).toEqual({ args: { name: 'trek', extra: 'kept' }, userId: 42 });
const loose = (await harness.client.listTools()).tools.find((t) => t.name === 'loose_tool');
expect(loose?.inputSchema).not.toHaveProperty('additionalProperties', false);
});
it('normalizes args to {} for tools declared without an inputSchema', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 42 });
const result = await harness.client.callTool({ name: 'open_tool', arguments: {} });
@@ -88,6 +88,7 @@ import fs from 'fs';
import path from 'path';
import { notificationsStub } from '../../helpers/notifications';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
// Real sibling services over the same in-memory DB — updateTrip's date-shift
// resyncs and the summary/bundle aggregation run their actual SQL.
@@ -128,6 +129,7 @@ const svc = new TripsService(
new RealtimeService(),
undefined as never, // unsplash — not exercised here
coversFx.storage,
new SettingsService(dbs()),
);
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
const readModelSvc = new TripReadModelService(
@@ -745,6 +747,34 @@ describe('folded trip CRUD', () => {
expect(getDays(tripId)).toHaveLength(3);
});
it('TRIP-SVC-069: create without a currency takes the display currency, admin default included, else EUR', () => {
const { user } = createUser(testDb);
const setUser = (value: string) =>
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value")
.run(user.id, JSON.stringify(value));
const setAdmin = (value: string) =>
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_default_currency', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(JSON.stringify(value));
const currencyOf = (data: Parameters<typeof svc.create>[1]) => (svc.create(user.id, data).trip as any).currency;
expect(currencyOf({ title: 'Nothing set' })).toBe('EUR');
// "Trip currency" in the settings stores an empty string, which counts as unset.
setUser('');
expect(currencyOf({ title: 'Cleared, no admin default' })).toBe('EUR');
setUser(' ');
expect(currencyOf({ title: 'Blank, no admin default' })).toBe('EUR');
setUser('');
setAdmin('CHF');
expect(currencyOf({ title: 'Admin default' })).toBe('CHF');
setUser('USD');
expect(currencyOf({ title: 'Own display currency' })).toBe('USD');
setUser('');
expect(currencyOf({ title: 'Back on the admin default' })).toBe('CHF');
// An explicit currency always wins.
setUser('USD');
expect(currencyOf({ title: 'Explicit', currency: 'JPY' })).toBe('JPY');
});
it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', () => {
const { user } = createUser(testDb);
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n;
@@ -1193,6 +1223,7 @@ describe('quirk fixes', () => {
new RealtimeService(),
undefined as never,
coversFx.storage,
new SettingsService(dbs()),
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ The short version: **the trip currency is the accounting base, the expense curre
## Trip currency
Every trip has exactly one currency. It is set when you create the trip and can be changed later in the trip edit dialog (requires the `trip_edit` permission). The new-trip dialog pre-fills it with **your display currency**, falling back to **EUR** when you have left that on *Trip currency*. This is the one moment the display currency touches stored data: it seeds the field, and from then on the trip's currency is its own.
Every trip has exactly one currency. It is set when you create the trip and can be changed later in the trip edit dialog (requires the `trip_edit` permission). The new-trip dialog pre-fills it with **your display currency**, falling back to **EUR** when you have left that on *Trip currency*. A trip created through MCP or by a plugin without a currency gets the same value. This is the one moment the display currency touches stored data: it seeds the field, and from then on the trip's currency is its own.
This is the trip's **accounting base**. It is not a cosmetic label:
+3 -1
View File
@@ -2,6 +2,8 @@
TREK exposes **tools** (read and write actions) and **resources** (read-only `trek://` URIs). Tools are registered per-session based on OAuth scopes and enabled addons.
Every built-in tool checks its arguments against its published schema before anything runs. An argument the tool does not declare (a misspelt field such as `stopType` for `stop_type`) is refused with `Unrecognized key`, so the AI sees the mistake and can retry rather than reporting a change that never happened. Plugin-contributed tools follow the schema their manifest declares.
For addon-gated tools (Packing, To-Dos, Atlas, Collab, Collections, Vacay, Journey, Dawarich, Document sync, Road trip) and their resources, see [MCP-Addon-Tools](MCP-Addon-Tools).
## Tools
@@ -31,7 +33,7 @@ Requires `trips:read` or `trips:write` scope.
| Tool | Description |
|---|---|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a trip with title, dates, and currency. Days are auto-generated from the date range. |
| `create_trip` | Create a trip with title, dates, and currency. Days are auto-generated from the date range. Without a currency the trip takes your display currency (Settings → General, or the admin's User Defaults preset), and EUR when that is left on *Trip currency*; see [Currencies](Currencies). |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `search_cover_images` | Search Unsplash for candidate cover photos and return their URLs, thumbnails and photographer credits. Nothing is saved; pass the chosen photo's URL to `update_trip` as `cover_image`. |
| `delete_trip` | Delete a trip. Owner only. Requires `trips:delete`. |
+3 -2
View File
@@ -152,7 +152,8 @@ permissions still requires explicit re-consent).
the home for per-day content like outfit planning, live flight status or logistics),
or a panel at the foot of a booking card in the **reservation-detail** view
(`reservation-detail` slot — receives the open `reservationId`, for things like
live check-in status or a seat map). Set the slot in `capabilities.widget.slot`.
live check-in status or a seat map; shown on the desktop panel and on the phone's
transport and booking cards). Set the slot in `capabilities.widget.slot`.
- **trip-page** — adds a tab **inside every trip planner**, so your UI lives in the
trip alongside Plan / Transports / Files. The frame is the same sandboxed iframe as
a `page`, but it receives the current `tripId` in `trek:context` (so you can scope
@@ -248,7 +249,7 @@ parser would ignore one if you added it.
| `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` / `tx(ops)` against your **own** SQLite file. `tx([{sql, args?}, …])` runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?}\|{rows?}, …] }`. Your file is capped at **256 MB** (a write past it fails `SQLITE_FULL`, contained to your plugin) and a single result set at **100,000 rows** — page your reads instead of materialising a cartesian product | `db:own` |
| `ctx.trips` | `getById` / `getPlaces` / `getReservations` / `getDays` / `getAccommodations` / `listMine()` — enumerate every trip the acting user can access (membership-checked). `getDays` includes each day's `assignments` + `notes_items`; `getReservations` includes `endpoints` + `day_positions` | `db:read:trips` |
| `ctx.trips.update(tripId, fields)` | update trip fields (title/dates/currency/reminder_days/…) | `db:write:trips` |
| `ctx.trips.create(input)` | create a **new trip owned by the acting user** (importers) — `title` required, plus `description?`/`start_date?`/`end_date?`/`currency?`/`reminder_days?`/`day_count?` | `db:create:trips` (+ `trip_create`) |
| `ctx.trips.create(input)` | create a **new trip owned by the acting user** (importers) — `title` required, plus `description?`/`start_date?`/`end_date?`/`currency?`/`reminder_days?`/`day_count?`; without `currency` the trip takes the acting user's display currency, then the instance default, then EUR | `db:create:trips` (+ `trip_create`) |
| `ctx.places` | `create(tripId, fields)` / `update(tripId, placeId, fields)` / `delete(tripId, placeId)` | `db:write:places` |
| `ctx.days` | `create(tripId, {date?, notes?})` / `update(tripId, dayId, {notes?, title?})` / `delete(tripId, dayId)` | `db:write:days` |
| `ctx.itinerary` | `assign(tripId, dayId, placeId, notes?)` / `unassign(tripId, assignmentId)` — place↔day | `db:write:itinerary` |