mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +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)
|
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||||
|
|
||||||
// A version is a short release tag and nothing else. It arrives over the
|
// 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
|
// wire and is written to this device's storage, so only a value made of
|
||||||
// characters a tag may contain and kept only when nothing had to be
|
// the characters a tag may contain is taken, as the match itself. Anything
|
||||||
// stripped. Anything else is ignored, which also keeps a malformed value
|
// else is ignored, which also keeps a malformed value from being compared
|
||||||
// from being compared against the stored marker and starting an update on
|
// against the stored marker and starting an update on every launch.
|
||||||
// every launch.
|
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
|
||||||
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
|
if (releaseTag) {
|
||||||
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
|
const version = releaseTag[0]
|
||||||
if (version && version === reportedVersion) {
|
|
||||||
const storedVersion = localStorage.getItem('trek_app_version')
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
// Record the version BEFORE acting on it. The old code wrote the marker
|
// 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
|
// 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 { useTranslation } from '../../../../i18n'
|
||||||
import type { Reservation } from '../../../../types'
|
import type { Reservation } from '../../../../types'
|
||||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
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 { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||||
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
|
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
|
||||||
import { BOOKING_TYPE_COLOR } from './bookingsModel'
|
import { BOOKING_TYPE_COLOR } from './bookingsModel'
|
||||||
@@ -262,6 +262,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||||
|
|
||||||
<MConfirmSheet
|
<MConfirmSheet
|
||||||
open={confirmDelete}
|
open={confirmDelete}
|
||||||
onClose={() => setConfirmDelete(false)}
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
|
|||||||
import { useTranslation } from '../../../../i18n'
|
import { useTranslation } from '../../../../i18n'
|
||||||
import type { Reservation } from '../../../../types'
|
import type { Reservation } from '../../../../types'
|
||||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
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 { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||||
import {
|
import {
|
||||||
TRANSPORT_TYPE_COLOR,
|
TRANSPORT_TYPE_COLOR,
|
||||||
@@ -280,6 +280,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||||
|
|
||||||
<MConfirmSheet
|
<MConfirmSheet
|
||||||
open={confirmDelete}
|
open={confirmDelete}
|
||||||
onClose={() => setConfirmDelete(false)}
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
|
|||||||
import type { ReservationTraveler } from '@trek/shared'
|
import type { ReservationTraveler } from '@trek/shared'
|
||||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
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,
|
* 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 }} />
|
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).
|
* Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
|
||||||
* The mobile transport / booking cards only display them — assignment editing
|
* 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 type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||||
import { openFile } from '../../../../src/utils/fileDownload'
|
import { openFile } from '../../../../src/utils/fileDownload'
|
||||||
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
||||||
|
import { usePluginStore } from '../../../../src/store/pluginStore'
|
||||||
import { buildSettings } from '../../../helpers/factories'
|
import { buildSettings } from '../../../helpers/factories'
|
||||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
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 => ({
|
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
|
||||||
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
||||||
openFile: vi.fn(),
|
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 = [
|
const DAYS = [
|
||||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
{ 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' },
|
{ 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', () => {
|
describe('MBookingsTab', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(openFile).mockClear()
|
vi.mocked(openFile).mockClear()
|
||||||
|
seedStore(usePluginStore, { plugins: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => {
|
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
|
// the check-in cell and the time field both read 09:00
|
||||||
expect(within(card).getAllByText('09:00')).toHaveLength(2)
|
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 type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||||
import { openFile } from '../../../../src/utils/fileDownload'
|
import { openFile } from '../../../../src/utils/fileDownload'
|
||||||
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
||||||
|
import { usePluginStore } from '../../../../src/store/pluginStore'
|
||||||
import { buildSettings } from '../../../helpers/factories'
|
import { buildSettings } from '../../../helpers/factories'
|
||||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
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 => ({
|
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
|
||||||
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
||||||
openFile: vi.fn(),
|
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 = [
|
const DAYS = [
|
||||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
{ 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' },
|
{ 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', () => {
|
describe('MTransportsTab', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(openFile).mockClear()
|
vi.mocked(openFile).mockClear()
|
||||||
|
seedStore(usePluginStore, { plugins: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => {
|
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
|
// nobody is assigned, so the traveler filter row stays away
|
||||||
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
|
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({
|
@Tool({
|
||||||
name: 'list_things',
|
name: 'list_things',
|
||||||
description: 'List all things.',
|
description: 'List all things.',
|
||||||
inputSchema: {}, // ZodRawShape, passed straight to the SDK
|
inputSchema: {}, // ZodRawShape, registered as a strict object
|
||||||
annotations: { readOnlyHint: true },
|
annotations: { readOnlyHint: true },
|
||||||
access: { group: 'things', mode: 'read' }, // resolved by the accessPolicy
|
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)`.
|
- `@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.)
|
- `@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)`.
|
- `@Prompt(options)` — mirrors `registerPrompt` (`argsSchema` entries must be string-valued Zod schemas). Handler: `(args, ctx)`.
|
||||||
|
|||||||
@@ -15,12 +15,24 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
|
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
interface BoundEntry {
|
interface BoundEntry {
|
||||||
entry: McpEntry;
|
entry: McpEntry;
|
||||||
instance: object;
|
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 {
|
export interface McpRegistryOptions {
|
||||||
accessPolicy?: McpAccessPolicy;
|
accessPolicy?: McpAccessPolicy;
|
||||||
validateAccess?: McpAccessValidator;
|
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
|
* Structural view of the SDK registration surface. The SDK's real signatures
|
||||||
* are generic over the Zod shapes; the registry passes schemas straight
|
* are generic over the Zod shapes; the registry hands schemas over as objects
|
||||||
* through and binds `ctx` where the SDK would pass `extra`, so it needs (and
|
* and binds `ctx` where the SDK would pass `extra`, so it needs (and exposes)
|
||||||
* exposes) none of that type-level machinery.
|
* none of that type-level machinery.
|
||||||
*/
|
*/
|
||||||
interface LooseRegistrar {
|
interface LooseRegistrar {
|
||||||
registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown;
|
registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown;
|
||||||
@@ -294,7 +306,7 @@ export class McpRegistry {
|
|||||||
const config: Record<string, unknown> = {
|
const config: Record<string, unknown> = {
|
||||||
title: options.title,
|
title: options.title,
|
||||||
description: options.description,
|
description: options.description,
|
||||||
inputSchema: options.inputSchema,
|
inputSchema: strictInputSchema(options.inputSchema),
|
||||||
outputSchema: options.outputSchema,
|
outputSchema: options.outputSchema,
|
||||||
annotations: options.annotations,
|
annotations: options.annotations,
|
||||||
_meta: options._meta,
|
_meta: options._meta,
|
||||||
|
|||||||
@@ -126,8 +126,9 @@ export interface McpZodSchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors the SDK's `registerTool` config. `inputSchema` is passed straight
|
* Mirrors the SDK's `registerTool` config. A shape `inputSchema` is registered
|
||||||
* through — the SDK does the parsing/validation.
|
* 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
|
* `annotations`/`_meta` are kept structural so the package's public d.ts
|
||||||
* never imports deep SDK subpaths.
|
* 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
|
* signed like the REST contract's (#2176): a negative payer is the recipient
|
||||||
* of a refund recorded as a negative expense.
|
* 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(),
|
user_id: z.number().int().positive(),
|
||||||
amount: z.number(),
|
amount: z.number(),
|
||||||
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
|
})).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). */
|
/** 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(),
|
user_id: z.number().int().positive(),
|
||||||
amount: z.number(),
|
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.');
|
})).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
|
* 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.
|
* 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([]),
|
pros: z.array(z.string()).default([]),
|
||||||
cons: z.array(z.string()).default([]),
|
cons: z.array(z.string()).default([]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class MapsMcp {
|
|||||||
z.enum(POI_CATEGORY_KEYS),
|
z.enum(POI_CATEGORY_KEYS),
|
||||||
z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES),
|
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'),
|
]).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'),
|
south: z.number().min(-90).max(90).describe('Southern edge, latitude'),
|
||||||
west: z.number().min(-180).max(180).describe('Western edge, longitude'),
|
west: z.number().min(-180).max(180).describe('Western edge, longitude'),
|
||||||
north: z.number().min(-90).max(90).describe('Northern edge, latitude'),
|
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.',
|
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: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
items: z.array(z.object({
|
items: z.array(z.strictObject({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
quantity: z.number().int().positive().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.',
|
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: {
|
inputSchema: {
|
||||||
query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'),
|
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()
|
.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'),
|
.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"'),
|
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 TransportType = typeof CREATABLE_TRANSPORT_TYPES[number];
|
||||||
type BookingType = typeof BOOKING_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)'),
|
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)'),
|
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")'),
|
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.',
|
description: 'Update the display order of reservations within a day.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
positions: z.array(z.object({
|
positions: z.array(z.strictObject({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
day_plan_position: z.number().int().min(0),
|
day_plan_position: z.number().int().min(0),
|
||||||
})).describe('Array of { id, day_plan_position } pairs'),
|
})).describe('Array of { id, day_plan_position } pairs'),
|
||||||
|
|||||||
@@ -89,14 +89,14 @@ export class RoadtripMcp {
|
|||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
dayId: 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'),
|
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),
|
lat: z.number().min(-90).max(90),
|
||||||
lng: z.number().min(-180).max(180),
|
lng: z.number().min(-180).max(180),
|
||||||
})).max(100).describe('In the order the drive passes through them'),
|
})).max(100).describe('In the order the drive passes through them'),
|
||||||
replace_legs: z.array(z.number().int().min(0)).max(100).optional()
|
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'),
|
.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'),
|
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()
|
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'),
|
.describe('How far the fitted route still runs from the track at its worst point'),
|
||||||
@@ -139,7 +139,7 @@ export class RoadtripMcp {
|
|||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
dayId: z.number().int().positive(),
|
dayId: z.number().int().positive(),
|
||||||
vias: z.array(z.object({
|
vias: z.array(z.strictObject({
|
||||||
id: z.number().int().positive(),
|
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'),
|
after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'),
|
||||||
})).max(500),
|
})).max(500),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export class TripsMcp {
|
|||||||
description: z.string().max(2000).optional().describe('Trip description'),
|
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)'),
|
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)'),
|
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(
|
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.'),
|
'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(
|
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 { StorageService } from '../storage/storage.service';
|
||||||
import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
|
import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
|
||||||
import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
|
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
|
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
|
||||||
* not capture the nested sub-domain mounts (collab, files, ...). */
|
* 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,
|
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],
|
controllers: [TripsController],
|
||||||
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
|
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
|
||||||
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
|
// 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 { VacayService } from '../vacay/vacay.service';
|
||||||
import { UnsplashService } from '../unsplash/unsplash.service';
|
import { UnsplashService } from '../unsplash/unsplash.service';
|
||||||
import { StorageService } from '../storage/storage.service';
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
import { SettingsService } from '../settings/settings.service';
|
||||||
import { NotFoundError, ValidationError } from '../common/domain-errors';
|
import { NotFoundError, ValidationError } from '../common/domain-errors';
|
||||||
|
|
||||||
export const MS_PER_DAY = 86400000;
|
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
|
* 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
|
* 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
|
* plus day generation, which is why the constructor is far shorter than the
|
||||||
* of fourteen. The SQL moved 1:1 from the legacy
|
* fourteen parameters it started with. The SQL moved 1:1 from the legacy
|
||||||
* services/tripService.ts: identical statements, the `||` falsy-coercion
|
* services/tripService.ts: identical statements, the `||` falsy-coercion
|
||||||
* defaults, the post-write TRIP_SELECT re-selects and the mixed
|
* defaults, the post-write TRIP_SELECT re-selects and the mixed
|
||||||
* named/positional parameter styles are all preserved byte-for-byte.
|
* named/positional parameter styles are all preserved byte-for-byte.
|
||||||
* Post-migration quirk fixes on top of the 1:1 move: the multi-statement
|
* Post-migration quirk fixes on top of the 1:1 move: the multi-statement
|
||||||
* deletes (remove, deleteGuest's re-split + user delete) run in
|
* deletes (remove, deleteGuest's re-split + user delete) run in
|
||||||
* db.transaction(), and listMembers' owner row COALESCEs display_name like
|
* db.transaction(), listMembers' owner row COALESCEs display_name like
|
||||||
* the member rows. Auth (canAccessTrip), per-field permission checks and
|
* 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);
|
* audit logging stay in the controller (1:1 with the legacy route);
|
||||||
* trip:updated / trip:deleted broadcasts stay in the controller too — this
|
* trip:updated / trip:deleted broadcasts stay in the controller too — this
|
||||||
* service emits none.
|
* service emits none.
|
||||||
@@ -159,12 +161,24 @@ export class TripsService {
|
|||||||
private readonly realtime: RealtimeService,
|
private readonly realtime: RealtimeService,
|
||||||
private readonly unsplash: UnsplashService,
|
private readonly unsplash: UnsplashService,
|
||||||
private readonly storage: StorageService,
|
private readonly storage: StorageService,
|
||||||
|
private readonly settings: SettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get db() {
|
private get db() {
|
||||||
return this.dbs.connection;
|
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) {
|
canAccessTrip(tripId: string | number, userId: number) {
|
||||||
return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
|
return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
|
||||||
}
|
}
|
||||||
@@ -328,7 +342,7 @@ export class TripsService {
|
|||||||
const result = this.db.prepare(`
|
const result = this.db.prepare(`
|
||||||
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
|
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
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;
|
const tripId = result.lastInsertRowid;
|
||||||
this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count);
|
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
|
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
||||||
// this at onModuleInit.
|
// this at onModuleInit.
|
||||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
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 };
|
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 trip_members').run();
|
||||||
db.prepare('DELETE FROM days').run();
|
db.prepare('DELETE FROM days').run();
|
||||||
db.prepare('DELETE FROM audit_log').run();
|
db.prepare('DELETE FROM audit_log').run();
|
||||||
|
db.prepare('DELETE FROM settings').run();
|
||||||
canAccessTrip.mockReturnValue({ user_id: 1 });
|
canAccessTrip.mockReturnValue({ user_id: 1 });
|
||||||
checkPermission.mockReturnValue(true);
|
checkPermission.mockReturnValue(true);
|
||||||
});
|
});
|
||||||
@@ -198,6 +202,16 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
|
|||||||
expect(forbidden.status).toBe(403);
|
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 () => {
|
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.
|
// 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))
|
const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1))
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
|||||||
realtimeService,
|
realtimeService,
|
||||||
new UnsplashService(dbService, new RuntimeEnvService(), generalStorage),
|
new UnsplashService(dbService, new RuntimeEnvService(), generalStorage),
|
||||||
generalStorage,
|
generalStorage,
|
||||||
|
new SettingsService(dbService),
|
||||||
);
|
);
|
||||||
const readModelService = new TripReadModelService(
|
const readModelService = new TripReadModelService(
|
||||||
dbService, membersService, daysService, accommodationsService, budgetService,
|
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);
|
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.
|
// 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 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 members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub());
|
||||||
const guards = new PluginGuards(dbs, permissions, addons);
|
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 () => {
|
it('sets image_url on an existing place (#37)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
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 });
|
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}` }] };
|
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 })
|
@Tool({ name: 'predicate_tool', access: (ctx: McpContext) => (ctx as TestCtx).allow === true })
|
||||||
async predicateTool(args: Record<string, never>, ctx: TestCtx) {
|
async predicateTool(args: Record<string, never>, ctx: TestCtx) {
|
||||||
return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] };
|
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 () => {
|
it('registers everything for a ctx passing all access checks', async () => {
|
||||||
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: true, allow: true });
|
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: true, allow: true });
|
||||||
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
|
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);
|
const resources = (await harness.client.listResources()).resources.map((r) => r.name);
|
||||||
expect(resources).toEqual(['fixture_doc']);
|
expect(resources).toEqual(['fixture_doc']);
|
||||||
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
|
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 () => {
|
it('filters declarative access through the policy per mode', async () => {
|
||||||
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: false, allow: false });
|
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: false, allow: false });
|
||||||
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
|
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 () => {
|
it('filters everything gated when the ctx grants nothing, keeping ungated entries', async () => {
|
||||||
harness = await createAttachHarness(buildRegistry(), { userId: 7 });
|
harness = await createAttachHarness(buildRegistry(), { userId: 7 });
|
||||||
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
|
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([]);
|
expect((await harness.client.listResources()).resources).toEqual([]);
|
||||||
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
|
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
|
||||||
expect(templates).toEqual(['fixture_item']);
|
expect(templates).toEqual(['fixture_item']);
|
||||||
@@ -188,6 +195,32 @@ describe('McpRegistry.attach', () => {
|
|||||||
expect(JSON.parse(textOf(result))).toEqual({ greeting: 'hello trek', userId: 42 });
|
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 () => {
|
it('normalizes args to {} for tools declared without an inputSchema', async () => {
|
||||||
harness = await createAttachHarness(buildRegistry(), { userId: 42 });
|
harness = await createAttachHarness(buildRegistry(), { userId: 42 });
|
||||||
const result = await harness.client.callTool({ name: 'open_tool', arguments: {} });
|
const result = await harness.client.callTool({ name: 'open_tool', arguments: {} });
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { notificationsStub } from '../../helpers/notifications';
|
import { notificationsStub } from '../../helpers/notifications';
|
||||||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
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
|
// Real sibling services over the same in-memory DB — updateTrip's date-shift
|
||||||
// resyncs and the summary/bundle aggregation run their actual SQL.
|
// resyncs and the summary/bundle aggregation run their actual SQL.
|
||||||
@@ -128,6 +129,7 @@ const svc = new TripsService(
|
|||||||
new RealtimeService(),
|
new RealtimeService(),
|
||||||
undefined as never, // unsplash — not exercised here
|
undefined as never, // unsplash — not exercised here
|
||||||
coversFx.storage,
|
coversFx.storage,
|
||||||
|
new SettingsService(dbs()),
|
||||||
);
|
);
|
||||||
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
|
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
|
||||||
const readModelSvc = new TripReadModelService(
|
const readModelSvc = new TripReadModelService(
|
||||||
@@ -745,6 +747,34 @@ describe('folded trip CRUD', () => {
|
|||||||
expect(getDays(tripId)).toHaveLength(3);
|
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', () => {
|
it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n;
|
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n;
|
||||||
@@ -1193,6 +1223,7 @@ describe('quirk fixes', () => {
|
|||||||
new RealtimeService(),
|
new RealtimeService(),
|
||||||
undefined as never,
|
undefined as never,
|
||||||
coversFx.storage,
|
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
|
## 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:
|
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.
|
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).
|
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
|
## Tools
|
||||||
@@ -31,7 +33,7 @@ Requires `trips:read` or `trips:write` scope.
|
|||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
|
| `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. |
|
| `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`. |
|
| `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`. |
|
| `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),
|
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
|
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
|
(`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-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
|
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
|
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.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` | `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.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.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.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` |
|
| `ctx.itinerary` | `assign(tripId, dayId, placeId, notes?)` / `unassign(tripId, assignmentId)` — place↔day | `db:write:itinerary` |
|
||||||
|
|||||||
Reference in New Issue
Block a user