mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e79d0996ae | |||
| 8936049a81 | |||
| c0b691b8e5 | |||
| aa64572bf0 | |||
| 23aec0d898 |
+7
-8
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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([]),
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user